@animalabs/agent-framework 0.12.0 → 0.14.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/dist/src/agent.d.ts +2 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +3 -0
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +76 -4
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +933 -151
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +62 -2
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +56 -2
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/index.d.ts +1 -1
- package/dist/src/gate/index.d.ts.map +1 -1
- package/dist/src/gate/index.js +1 -1
- package/dist/src/gate/index.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +84 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +160 -4
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +5 -1
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/tool-image-ledger.d.ts +175 -0
- package/dist/src/tool-image-ledger.d.ts.map +1 -0
- package/dist/src/tool-image-ledger.js +257 -0
- package/dist/src/tool-image-ledger.js.map +1 -0
- package/dist/src/tool-result-history.d.ts +15 -2
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +16 -9
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/tool-wrapper-prose-guard.d.ts +8 -0
- package/dist/src/tool-wrapper-prose-guard.d.ts.map +1 -0
- package/dist/src/tool-wrapper-prose-guard.js +37 -0
- package/dist/src/tool-wrapper-prose-guard.js.map +1 -0
- package/dist/src/tune-out/coordinator.d.ts +144 -0
- package/dist/src/tune-out/coordinator.d.ts.map +1 -0
- package/dist/src/tune-out/coordinator.js +444 -0
- package/dist/src/tune-out/coordinator.js.map +1 -0
- package/dist/src/tune-out/tools.d.ts +43 -0
- package/dist/src/tune-out/tools.d.ts.map +1 -0
- package/dist/src/tune-out/tools.js +93 -0
- package/dist/src/tune-out/tools.js.map +1 -0
- package/dist/src/types/agent.d.ts +17 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +6 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/test/channel-incoming-targeting.test.d.ts +2 -0
- package/dist/test/channel-incoming-targeting.test.d.ts.map +1 -0
- package/dist/test/channel-incoming-targeting.test.js +81 -0
- package/dist/test/channel-incoming-targeting.test.js.map +1 -0
- package/dist/test/event-gate.test.js +84 -1
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/per-agent-delivery.test.d.ts +2 -0
- package/dist/test/per-agent-delivery.test.d.ts.map +1 -0
- package/dist/test/per-agent-delivery.test.js +108 -0
- package/dist/test/per-agent-delivery.test.js.map +1 -0
- package/dist/test/puppet-tool-call.test.js +1 -0
- package/dist/test/puppet-tool-call.test.js.map +1 -1
- package/dist/test/save-recent-image.test.d.ts +26 -0
- package/dist/test/save-recent-image.test.d.ts.map +1 -0
- package/dist/test/save-recent-image.test.js +707 -0
- package/dist/test/save-recent-image.test.js.map +1 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts +2 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts.map +1 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.js +485 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +1 -0
- package/dist/test/tool-wrapper-prose-guard.test.d.ts +2 -0
- package/dist/test/tool-wrapper-prose-guard.test.d.ts.map +1 -0
- package/dist/test/tool-wrapper-prose-guard.test.js +34 -0
- package/dist/test/tool-wrapper-prose-guard.test.js.map +1 -0
- package/dist/test/tune-out-e2e.test.d.ts +2 -0
- package/dist/test/tune-out-e2e.test.d.ts.map +1 -0
- package/dist/test/tune-out-e2e.test.js +142 -0
- package/dist/test/tune-out-e2e.test.js.map +1 -0
- package/dist/test/tune-out-gate-composition.test.d.ts +2 -0
- package/dist/test/tune-out-gate-composition.test.d.ts.map +1 -0
- package/dist/test/tune-out-gate-composition.test.js +221 -0
- package/dist/test/tune-out-gate-composition.test.js.map +1 -0
- package/dist/test/tune-out-lifecycle.test.d.ts +2 -0
- package/dist/test/tune-out-lifecycle.test.d.ts.map +1 -0
- package/dist/test/tune-out-lifecycle.test.js +121 -0
- package/dist/test/tune-out-lifecycle.test.js.map +1 -0
- package/dist/test/turn-trigger-provenance.test.js +17 -0
- package/dist/test/turn-trigger-provenance.test.js.map +1 -1
- package/dist/test/workspace-read-image.test.js +8 -0
- package/dist/test/workspace-read-image.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +3 -0
- package/src/framework.ts +1051 -154
- package/src/gate/event-gate.ts +99 -6
- package/src/gate/index.ts +1 -1
- package/src/index.ts +1 -1
- package/src/mcpl/channel-registry.ts +235 -7
- package/src/modules/workspace/index.ts +5 -1
- package/src/tool-image-ledger.ts +334 -0
- package/src/tool-result-history.ts +30 -9
- package/src/tool-wrapper-prose-guard.ts +38 -0
- package/src/tune-out/coordinator.ts +548 -0
- package/src/tune-out/tools.ts +129 -0
- package/src/types/agent.ts +18 -0
- package/src/types/framework.ts +7 -0
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `save_recent_image` provenance — issue #104.
|
|
3
|
+
*
|
|
4
|
+
* Tool-result images never reached the persisted window (history keeps a
|
|
5
|
+
* text placeholder), so the recency scan walked past a snapshot the resident
|
|
6
|
+
* had just seen and quietly saved an OLDER attachment under the snapshot's
|
|
7
|
+
* filename. These tests pin the repaired contract:
|
|
8
|
+
*
|
|
9
|
+
* 1. index 0 after a tool image IS that tool image, byte-exact, even with
|
|
10
|
+
* an older attachment in context; index 1 is the attachment.
|
|
11
|
+
* 2. when the bytes behind the newest image are gone, the save fails at
|
|
12
|
+
* that index and writes nothing — never the older attachment.
|
|
13
|
+
* 3. placeholders written before retention existed are unsaveable slots,
|
|
14
|
+
* not skipped ones.
|
|
15
|
+
* 4. `ref` saves by provenance; an unknown ref is a defined miss.
|
|
16
|
+
* 5. receipts carry source, tool call, MIME, size and SHA-256.
|
|
17
|
+
*
|
|
18
|
+
* Second review round (#140) added three more representations of the same
|
|
19
|
+
* failure, each pinned below:
|
|
20
|
+
* 6. truncation/spill never drops an image slot from the stored text.
|
|
21
|
+
* 7. a save dispatched in the same batch as the snapshot waits for it.
|
|
22
|
+
* 8. refs are namespaced per ledger — a stale ref resolves to nothing,
|
|
23
|
+
* and a direct `ref` is saved through its occurrence in context.
|
|
24
|
+
*/
|
|
25
|
+
import { describe, it } from 'node:test';
|
|
26
|
+
import assert from 'node:assert';
|
|
27
|
+
import { createHash } from 'node:crypto';
|
|
28
|
+
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
|
29
|
+
import { tmpdir } from 'node:os';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
import { AgentFramework } from '../src/index.js';
|
|
32
|
+
import { WorkspaceModule } from '../src/modules/workspace/index.js';
|
|
33
|
+
import { FALLBACK_MEDIA_TYPE, TOOL_IMAGE_REF_RE, ToolImageLedger, formatPreservedImageSlots, formatToolImagePlaceholder, normalizeMediaType, parseImagePlaceholders, splitPreservingImageSlots, } from '../src/tool-image-ledger.js';
|
|
34
|
+
import { toolResultDataToHistoryString, truncateForHistory } from '../src/tool-result-history.js';
|
|
35
|
+
import { createMockResponse, MockMembrane } from './helpers/mock-membrane.js';
|
|
36
|
+
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg==', 'base64');
|
|
37
|
+
const GIF = Buffer.from('R0lGODdhAQABAIEAAP///wAAAAAAAAAAACwAAAAAAQABAAAIBAABBAQAOw==', 'base64');
|
|
38
|
+
const sha = (b) => createHash('sha256').update(b).digest('hex');
|
|
39
|
+
const REF_1 = /^img_[0-9a-z]+_1$/;
|
|
40
|
+
/** ~30k chars — past the smallest allowed inline cap (1000) many times over. */
|
|
41
|
+
const LONG_TEXT = 'line of scan output\n'.repeat(1500);
|
|
42
|
+
/** World module: `snap` returns a native PNG image block; the other tools
|
|
43
|
+
* are the review's representations of the same result, one shape each. */
|
|
44
|
+
class SnapModule {
|
|
45
|
+
name = 'world';
|
|
46
|
+
/** The `quote` tool cites this ref — set by the test once it knows it. */
|
|
47
|
+
quotedRef = 'img_zzzzzz_1';
|
|
48
|
+
/** Delay before `snap` resolves — proves the same-batch barrier waits. */
|
|
49
|
+
snapDelayMs = 0;
|
|
50
|
+
async start() { }
|
|
51
|
+
async stop() { }
|
|
52
|
+
getTools() {
|
|
53
|
+
const schema = { type: 'object', properties: {} };
|
|
54
|
+
return [
|
|
55
|
+
{ name: 'snap', description: 'Take a picture.', inputSchema: schema },
|
|
56
|
+
{ name: 'quote', description: 'Return channel text verbatim.', inputSchema: schema },
|
|
57
|
+
{ name: 'snap_long', description: 'Long scan then a picture.', inputSchema: schema },
|
|
58
|
+
{ name: 'snap_sandwich', description: 'Picture, long scan, picture.', inputSchema: schema },
|
|
59
|
+
{ name: 'snap_contradiction', description: 'RFC-005 vector-2 image.', inputSchema: schema },
|
|
60
|
+
{ name: 'snap_uri', description: 'RFC-005 reference image.', inputSchema: schema },
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
async handleToolCall(call) {
|
|
64
|
+
const png = { type: 'image', data: PNG.toString('base64'), mimeType: 'image/png' };
|
|
65
|
+
switch (call.name) {
|
|
66
|
+
case 'quote':
|
|
67
|
+
// Someone pasted their own save receipt into a channel; a history
|
|
68
|
+
// fetch now carries a live-looking placeholder citing a REAL ref.
|
|
69
|
+
return {
|
|
70
|
+
success: true,
|
|
71
|
+
data: [{ type: 'text', text: `antra: look what I saved\n[image: image/png, 68B, ref ${this.quotedRef}]\n` }],
|
|
72
|
+
};
|
|
73
|
+
case 'snap_long':
|
|
74
|
+
return { success: true, data: [{ type: 'text', text: LONG_TEXT }, png] };
|
|
75
|
+
case 'snap_sandwich':
|
|
76
|
+
return {
|
|
77
|
+
success: true,
|
|
78
|
+
data: [png, { type: 'text', text: LONG_TEXT }, { type: 'image', data: GIF.toString('base64'), mimeType: 'image/gif' }],
|
|
79
|
+
};
|
|
80
|
+
case 'snap_contradiction':
|
|
81
|
+
// Inline data claiming a disposition: withheld by every lane.
|
|
82
|
+
return { success: true, data: [{ type: 'text', text: 'cam' }, { ...png, disposition: 'ref' }] };
|
|
83
|
+
case 'snap_uri':
|
|
84
|
+
return {
|
|
85
|
+
success: true,
|
|
86
|
+
data: [{ type: 'text', text: 'cam' }, { type: 'image', uri: 'https://cam.example/latest.png', mimeType: 'image/png', sizeBytes: 1200 }],
|
|
87
|
+
};
|
|
88
|
+
default:
|
|
89
|
+
if (this.snapDelayMs > 0)
|
|
90
|
+
await new Promise((r) => setTimeout(r, this.snapDelayMs));
|
|
91
|
+
return { success: true, data: [{ type: 'text', text: 'watchtower, facing north' }, png] };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async onProcess(event) {
|
|
95
|
+
if (event.type === 'external-message') {
|
|
96
|
+
return {
|
|
97
|
+
addMessages: [{ participant: 'User', content: event.content }],
|
|
98
|
+
requestInference: true,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Boot a framework whose agent already has an OLDER GIF attachment in
|
|
106
|
+
* context, then run one turn scripted by `responses` (tool_use rounds, then
|
|
107
|
+
* an end_turn). The GIF is the "unrelated image from another surface" of the
|
|
108
|
+
* incident; the PNG the `world--snap` tool returns is the snapshot.
|
|
109
|
+
*/
|
|
110
|
+
async function startTurn(opts) {
|
|
111
|
+
const tempDir = mkdtempSync(join(tmpdir(), opts.prefix));
|
|
112
|
+
const mountDir = join(tempDir, 'mount');
|
|
113
|
+
mkdirSync(mountDir, { recursive: true });
|
|
114
|
+
const membrane = new MockMembrane();
|
|
115
|
+
for (const content of opts.responses) {
|
|
116
|
+
const hasToolUse = content.some((b) => b.type === 'tool_use');
|
|
117
|
+
membrane.pushResponse(createMockResponse(content, hasToolUse ? 'tool_use' : 'end_turn'));
|
|
118
|
+
}
|
|
119
|
+
const workspace = new WorkspaceModule({
|
|
120
|
+
mounts: [{ name: 'files', path: mountDir, mode: 'read-write', watch: 'never' }],
|
|
121
|
+
});
|
|
122
|
+
const world = new SnapModule();
|
|
123
|
+
opts.world?.(world);
|
|
124
|
+
const framework = await AgentFramework.create({
|
|
125
|
+
storePath: join(tempDir, 'store'),
|
|
126
|
+
membrane: membrane.asMembrane(),
|
|
127
|
+
agents: [{ name: 'prime', model: 'test-model', systemPrompt: 'You are prime.', allowedTools: 'all' }],
|
|
128
|
+
modules: [world, workspace],
|
|
129
|
+
syncIntervalMs: 0,
|
|
130
|
+
...(opts.toolResultInlineMaxChars !== undefined ? { toolResultInlineMaxChars: opts.toolResultInlineMaxChars } : {}),
|
|
131
|
+
});
|
|
132
|
+
workspace.initStore(framework.getStore());
|
|
133
|
+
// Install the ledger up front so tests know the nonce its refs will carry.
|
|
134
|
+
const ledger = opts.ledger ?? new ToolImageLedger();
|
|
135
|
+
framework
|
|
136
|
+
.toolImageLedgers.set('prime', ledger);
|
|
137
|
+
// The older attachment: a GIF the resident saw in an ordinary message.
|
|
138
|
+
framework.getAgent('prime').getContextManager().addMessage('user', [
|
|
139
|
+
{ type: 'text', text: 'here is my avatar' },
|
|
140
|
+
{ type: 'image', source: { type: 'base64', data: GIF.toString('base64'), mediaType: 'image/gif' } },
|
|
141
|
+
]);
|
|
142
|
+
opts.seedHistory?.(framework);
|
|
143
|
+
framework.start();
|
|
144
|
+
framework.pushEvent({
|
|
145
|
+
type: 'external-message',
|
|
146
|
+
source: 'test',
|
|
147
|
+
content: [{ type: 'text', text: 'take a picture and save it' }],
|
|
148
|
+
metadata: {},
|
|
149
|
+
triggerInference: true,
|
|
150
|
+
});
|
|
151
|
+
return { framework, membrane, workspace, world, ledger, mountDir, tempDir };
|
|
152
|
+
}
|
|
153
|
+
const refOf = (h, seq) => `img_${h.ledger.nonce}_${seq}`;
|
|
154
|
+
/** Stored tool_result for `callId`, once the framework has committed it. */
|
|
155
|
+
async function waitForToolResult(framework, callId, timeoutMs = 20_000) {
|
|
156
|
+
const deadline = Date.now() + timeoutMs;
|
|
157
|
+
while (Date.now() < deadline) {
|
|
158
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
159
|
+
const cm = framework.getAgent('prime')?.getContextManager();
|
|
160
|
+
const msgs = (cm?.queryMessages({}).messages ?? []);
|
|
161
|
+
for (const m of msgs) {
|
|
162
|
+
for (const b of m.content ?? []) {
|
|
163
|
+
if (b.type === 'tool_result' && b.toolUseId === callId) {
|
|
164
|
+
return { content: b.content, isError: b.isError === true };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
throw new Error(`no stored tool_result for ${callId} within ${timeoutMs}ms`);
|
|
170
|
+
}
|
|
171
|
+
/** Bytes at a mount path via the workspace's own read path (tree-first), or null. */
|
|
172
|
+
async function fileBytes(h, path) {
|
|
173
|
+
const read = await h.workspace.readBinary(path);
|
|
174
|
+
return 'data' in read ? read.data : null;
|
|
175
|
+
}
|
|
176
|
+
function saved(result) {
|
|
177
|
+
assert.strictEqual(result.isError, false, `expected success, got: ${result.content}`);
|
|
178
|
+
const parsed = JSON.parse(result.content);
|
|
179
|
+
return parsed.saved;
|
|
180
|
+
}
|
|
181
|
+
const snapCall = (id) => ({ type: 'tool_use', id, name: 'world--snap', input: {} });
|
|
182
|
+
const saveCall = (id, input) => ({ type: 'tool_use', id, name: 'save_recent_image', input });
|
|
183
|
+
const done = [{ type: 'text', text: 'Done.' }];
|
|
184
|
+
describe('save_recent_image provenance (issue #104)', () => {
|
|
185
|
+
it('index 0 after a tool image saves THAT image byte-exactly; index 1 is the older attachment', async () => {
|
|
186
|
+
const h = await startTurn({
|
|
187
|
+
prefix: 'sri-order-',
|
|
188
|
+
responses: [
|
|
189
|
+
[snapCall('call_snap')],
|
|
190
|
+
[saveCall('call_save0', { path: 'files/watchtower.png', index: 0 })],
|
|
191
|
+
[saveCall('call_save1', { path: 'files/avatar.gif', index: 1 })],
|
|
192
|
+
done,
|
|
193
|
+
],
|
|
194
|
+
});
|
|
195
|
+
try {
|
|
196
|
+
const snap = await waitForToolResult(h.framework, 'call_snap');
|
|
197
|
+
assert.match(snap.content, /\[image: image\/png, \d+B, ref img_[0-9a-z]+_1\]/, 'placeholder carries the ref');
|
|
198
|
+
assert.ok(!snap.content.includes(PNG.toString('base64')), 'history never holds the base64');
|
|
199
|
+
const first = saved(await waitForToolResult(h.framework, 'call_save0'));
|
|
200
|
+
assert.strictEqual(first.length, 1);
|
|
201
|
+
assert.strictEqual(first[0].source, 'tool-result');
|
|
202
|
+
assert.strictEqual(first[0].ref, refOf(h, 1));
|
|
203
|
+
assert.strictEqual(first[0].toolName, 'world--snap');
|
|
204
|
+
assert.strictEqual(first[0].toolCallId, 'call_snap');
|
|
205
|
+
assert.strictEqual(first[0].mediaType, 'image/png');
|
|
206
|
+
assert.strictEqual(first[0].byteSize, PNG.byteLength);
|
|
207
|
+
assert.strictEqual(first[0].sha256, sha(PNG));
|
|
208
|
+
assert.strictEqual(first[0].imageIndex, 0);
|
|
209
|
+
assert.ok((await fileBytes(h, 'files/watchtower.png'))?.equals(PNG), 'saved bytes are the snapshot');
|
|
210
|
+
const second = saved(await waitForToolResult(h.framework, 'call_save1'));
|
|
211
|
+
assert.strictEqual(second[0].source, 'attachment');
|
|
212
|
+
assert.strictEqual(second[0].sha256, sha(GIF));
|
|
213
|
+
assert.strictEqual(second[0].imageIndex, 1);
|
|
214
|
+
assert.ok((await fileBytes(h, 'files/avatar.gif'))?.equals(GIF), 'index 1 is the attachment');
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
await h.framework.stop();
|
|
218
|
+
rmSync(h.tempDir, { recursive: true, force: true });
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
it('fails at the exact index when the newest image was evicted — never saves the older attachment', async () => {
|
|
222
|
+
// A ledger that holds nothing: every retained image is evicted at once,
|
|
223
|
+
// which is what a restart or a blown budget looks like from the scan.
|
|
224
|
+
const h = await startTurn({
|
|
225
|
+
prefix: 'sri-evicted-',
|
|
226
|
+
responses: [
|
|
227
|
+
[snapCall('call_snap')],
|
|
228
|
+
[saveCall('call_save', { path: 'files/watchtower.png', index: 0 })],
|
|
229
|
+
done,
|
|
230
|
+
],
|
|
231
|
+
ledger: new ToolImageLedger({ maxEntries: 0 }),
|
|
232
|
+
});
|
|
233
|
+
try {
|
|
234
|
+
const snap = await waitForToolResult(h.framework, 'call_snap');
|
|
235
|
+
assert.match(snap.content, /ref img_[0-9a-z]+_1\]/, 'ref is still issued so the placeholder is honest');
|
|
236
|
+
const result = await waitForToolResult(h.framework, 'call_save');
|
|
237
|
+
assert.strictEqual(result.isError, true);
|
|
238
|
+
assert.match(result.content, new RegExp(`image at index 0 \\(${refOf(h, 1)}, from world--snap \\(call call_snap\\)\\) is no longer retained`));
|
|
239
|
+
assert.match(result.content, /evicted/);
|
|
240
|
+
assert.match(result.content, /Nothing written/);
|
|
241
|
+
assert.ok((await fileBytes(h, 'files/watchtower.png')) === null, 'no file under the snapshot name');
|
|
242
|
+
assert.ok((await fileBytes(h, 'files/avatar.gif')) === null);
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
await h.framework.stop();
|
|
246
|
+
rmSync(h.tempDir, { recursive: true, force: true });
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
it('a placeholder from before retention existed is an unsaveable slot, not a skipped one', async () => {
|
|
250
|
+
const h = await startTurn({
|
|
251
|
+
prefix: 'sri-legacy-',
|
|
252
|
+
responses: [
|
|
253
|
+
[saveCall('call_save', { path: 'files/phoenix.png', index: 0 })],
|
|
254
|
+
done,
|
|
255
|
+
],
|
|
256
|
+
seedHistory: (framework) => {
|
|
257
|
+
framework.getAgent('prime').getContextManager().addMessage('user', [{
|
|
258
|
+
type: 'tool_result',
|
|
259
|
+
toolUseId: 'call_old',
|
|
260
|
+
toolName: 'world--snap',
|
|
261
|
+
content: 'phoenix over the field\n[image: image/png, ~691KB]',
|
|
262
|
+
isError: false,
|
|
263
|
+
}]);
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
try {
|
|
267
|
+
const result = await waitForToolResult(h.framework, 'call_save');
|
|
268
|
+
assert.strictEqual(result.isError, true);
|
|
269
|
+
assert.match(result.content, /index 0 is a tool-result image from world--snap \(call call_old\) recorded without a retention ref/);
|
|
270
|
+
assert.ok((await fileBytes(h, 'files/phoenix.png')) === null, 'the older GIF must not be written under the PNG name');
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
await h.framework.stop();
|
|
274
|
+
rmSync(h.tempDir, { recursive: true, force: true });
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
it('`ref` saves by provenance; an unknown ref is a defined miss', async () => {
|
|
278
|
+
const ledger = new ToolImageLedger();
|
|
279
|
+
const ref1 = `img_${ledger.nonce}_1`;
|
|
280
|
+
const h = await startTurn({
|
|
281
|
+
prefix: 'sri-ref-',
|
|
282
|
+
ledger,
|
|
283
|
+
responses: [
|
|
284
|
+
[snapCall('call_snap')],
|
|
285
|
+
[saveCall('call_by_ref', { path: 'files/by-ref.png', ref: ref1 })],
|
|
286
|
+
[saveCall('call_bad_ref', { path: 'files/ghost.png', ref: `img_${ledger.nonce}_42` })],
|
|
287
|
+
[saveCall('call_bad_grammar', { path: 'files/ghost2.png', ref: 'img_1' })],
|
|
288
|
+
[saveCall('call_mixed', { path: 'files/mixed.png', ref: ref1, index: 0 })],
|
|
289
|
+
done,
|
|
290
|
+
],
|
|
291
|
+
});
|
|
292
|
+
try {
|
|
293
|
+
const byRef = saved(await waitForToolResult(h.framework, 'call_by_ref'));
|
|
294
|
+
assert.strictEqual(byRef[0].ref, ref1);
|
|
295
|
+
assert.strictEqual(byRef[0].sha256, sha(PNG));
|
|
296
|
+
assert.strictEqual(byRef[0].toolCallId, 'call_snap');
|
|
297
|
+
assert.ok((await fileBytes(h, 'files/by-ref.png'))?.equals(PNG));
|
|
298
|
+
const bad = await waitForToolResult(h.framework, 'call_bad_ref');
|
|
299
|
+
assert.strictEqual(bad.isError, true);
|
|
300
|
+
assert.match(bad.content, new RegExp(`ref img_${ledger.nonce}_42 cannot be saved — this process has no record of that ref`));
|
|
301
|
+
assert.ok((await fileBytes(h, 'files/ghost.png')) === null);
|
|
302
|
+
// The pre-nonce spelling is not a ref at all.
|
|
303
|
+
const grammar = await waitForToolResult(h.framework, 'call_bad_grammar');
|
|
304
|
+
assert.strictEqual(grammar.isError, true);
|
|
305
|
+
assert.match(grammar.content, /`ref` must look like/);
|
|
306
|
+
const mixed = await waitForToolResult(h.framework, 'call_mixed');
|
|
307
|
+
assert.strictEqual(mixed.isError, true);
|
|
308
|
+
assert.match(mixed.content, /mutually exclusive/);
|
|
309
|
+
}
|
|
310
|
+
finally {
|
|
311
|
+
await h.framework.stop();
|
|
312
|
+
rmSync(h.tempDir, { recursive: true, force: true });
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
it('a placeholder quoted inside another tool result never resolves to that ref\'s bytes', async () => {
|
|
316
|
+
// Refs are sequential and guessable; the quoted text cites the real
|
|
317
|
+
// img_1. The slot must fail on provenance mismatch, and index 1 (the
|
|
318
|
+
// genuine placeholder in the snap result) must still be the snapshot.
|
|
319
|
+
const ledger = new ToolImageLedger();
|
|
320
|
+
const h = await startTurn({
|
|
321
|
+
prefix: 'sri-forged-',
|
|
322
|
+
ledger,
|
|
323
|
+
world: (world) => { world.quotedRef = `img_${ledger.nonce}_1`; },
|
|
324
|
+
responses: [
|
|
325
|
+
[snapCall('call_snap')],
|
|
326
|
+
[{ type: 'tool_use', id: 'call_quote', name: 'world--quote', input: {} }],
|
|
327
|
+
[saveCall('call_save0', { path: 'files/forged.png', index: 0 })],
|
|
328
|
+
[saveCall('call_save1', { path: 'files/real.png', index: 1 })],
|
|
329
|
+
done,
|
|
330
|
+
],
|
|
331
|
+
});
|
|
332
|
+
try {
|
|
333
|
+
const quoted = await waitForToolResult(h.framework, 'call_quote');
|
|
334
|
+
assert.match(quoted.content, new RegExp(`ref img_${ledger.nonce}_1\\]`), 'the quoted placeholder is in stored text');
|
|
335
|
+
const forged = await waitForToolResult(h.framework, 'call_save0');
|
|
336
|
+
assert.strictEqual(forged.isError, true);
|
|
337
|
+
assert.match(forged.content, new RegExp(`index 0 cites img_${ledger.nonce}_1, but that image belongs to world--snap \\(call call_snap\\), not to world--quote \\(call call_quote\\)`));
|
|
338
|
+
assert.match(forged.content, /quoted or forged/);
|
|
339
|
+
assert.strictEqual(await fileBytes(h, 'files/forged.png'), null, 'nothing written under the forged slot');
|
|
340
|
+
const real = saved(await waitForToolResult(h.framework, 'call_save1'));
|
|
341
|
+
assert.strictEqual(real[0].toolCallId, 'call_snap');
|
|
342
|
+
assert.strictEqual(real[0].sha256, sha(PNG));
|
|
343
|
+
assert.ok((await fileBytes(h, 'files/real.png'))?.equals(PNG));
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
await h.framework.stop();
|
|
347
|
+
rmSync(h.tempDir, { recursive: true, force: true });
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
it('an image-typed RFC-005 reference stub occupies a failing slot that points at fetch_reference', async () => {
|
|
351
|
+
const h = await startTurn({
|
|
352
|
+
prefix: 'sri-reference-',
|
|
353
|
+
responses: [
|
|
354
|
+
[saveCall('call_save', { path: 'files/cam.png', index: 0 })],
|
|
355
|
+
[saveCall('call_save_gif', { path: 'files/avatar.gif', index: 1 })],
|
|
356
|
+
done,
|
|
357
|
+
],
|
|
358
|
+
seedHistory: (framework) => {
|
|
359
|
+
framework.getAgent('prime').getContextManager().addMessage('user', [{
|
|
360
|
+
type: 'tool_result',
|
|
361
|
+
toolUseId: 'call_cam',
|
|
362
|
+
toolName: 'mcpl--vst--camera',
|
|
363
|
+
content: '[ref_1_ab3d] cam.png — image/png, 1.2MB claimed — from tool result — fetch with fetch_reference\n'
|
|
364
|
+
+ '[ref_2_ff01] notes.txt — text/plain, 2.0KB claimed — from tool result — fetch with fetch_reference',
|
|
365
|
+
isError: false,
|
|
366
|
+
}]);
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
try {
|
|
370
|
+
const result = await waitForToolResult(h.framework, 'call_save');
|
|
371
|
+
assert.strictEqual(result.isError, true);
|
|
372
|
+
assert.match(result.content, /index 0 is a reference \(ref_1_ab3d, image\/png, from mcpl--vst--camera \(call call_cam\)\)/);
|
|
373
|
+
assert.match(result.content, /fetch_reference/);
|
|
374
|
+
assert.strictEqual(await fileBytes(h, 'files/cam.png'), null);
|
|
375
|
+
// The text/plain reference is not an image slot: index 1 is the GIF.
|
|
376
|
+
const gif = saved(await waitForToolResult(h.framework, 'call_save_gif'));
|
|
377
|
+
assert.strictEqual(gif[0].source, 'attachment');
|
|
378
|
+
assert.strictEqual(gif[0].sha256, sha(GIF));
|
|
379
|
+
}
|
|
380
|
+
finally {
|
|
381
|
+
await h.framework.stop();
|
|
382
|
+
rmSync(h.tempDir, { recursive: true, force: true });
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
it('truncation past the inline cap keeps the image slot: index 0 is still the snapshot', async () => {
|
|
386
|
+
// [30k text, image] at the smallest allowed cap. The wire delivered the
|
|
387
|
+
// image; the stored text must still carry its slot after the cut.
|
|
388
|
+
const h = await startTurn({
|
|
389
|
+
prefix: 'sri-truncated-',
|
|
390
|
+
toolResultInlineMaxChars: 1000,
|
|
391
|
+
responses: [
|
|
392
|
+
[{ type: 'tool_use', id: 'call_long', name: 'world--snap_long', input: {} }],
|
|
393
|
+
[saveCall('call_save', { path: 'files/after-cut.png', index: 0 })],
|
|
394
|
+
done,
|
|
395
|
+
],
|
|
396
|
+
});
|
|
397
|
+
try {
|
|
398
|
+
const stored = await waitForToolResult(h.framework, 'call_long');
|
|
399
|
+
assert.match(stored.content, /\[truncated — showing \d+ of \d+ chars/);
|
|
400
|
+
assert.match(stored.content, /1 image slot\(s\) fell past the cut/);
|
|
401
|
+
const slots = parseImagePlaceholders(stored.content);
|
|
402
|
+
assert.strictEqual(slots.length, 1, `exactly one slot survives the cut: ${stored.content.slice(-300)}`);
|
|
403
|
+
assert.strictEqual(slots[0].kind === 'inline' ? slots[0].ref : null, refOf(h, 1));
|
|
404
|
+
const receipt = saved(await waitForToolResult(h.framework, 'call_save'));
|
|
405
|
+
assert.strictEqual(receipt[0].toolCallId, 'call_long');
|
|
406
|
+
assert.strictEqual(receipt[0].sha256, sha(PNG));
|
|
407
|
+
assert.ok((await fileBytes(h, 'files/after-cut.png'))?.equals(PNG), 'the snapshot, not the older GIF');
|
|
408
|
+
}
|
|
409
|
+
finally {
|
|
410
|
+
await h.framework.stop();
|
|
411
|
+
rmSync(h.tempDir, { recursive: true, force: true });
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
it('[image A, huge text, image B] truncated: index 0 is B, index 1 is A — order survives the cut', async () => {
|
|
415
|
+
const h = await startTurn({
|
|
416
|
+
prefix: 'sri-sandwich-',
|
|
417
|
+
toolResultInlineMaxChars: 1000,
|
|
418
|
+
responses: [
|
|
419
|
+
[{ type: 'tool_use', id: 'call_sandwich', name: 'world--snap_sandwich', input: {} }],
|
|
420
|
+
[saveCall('call_save_b', { path: 'files/b.gif', index: 0 })],
|
|
421
|
+
[saveCall('call_save_a', { path: 'files/a.png', index: 1 })],
|
|
422
|
+
done,
|
|
423
|
+
],
|
|
424
|
+
});
|
|
425
|
+
try {
|
|
426
|
+
const stored = await waitForToolResult(h.framework, 'call_sandwich');
|
|
427
|
+
assert.deepStrictEqual(parseImagePlaceholders(stored.content).map((p) => (p.kind === 'inline' ? p.ref : p.refId)), [refOf(h, 1), refOf(h, 2)], 'both slots, in the original order');
|
|
428
|
+
const b = saved(await waitForToolResult(h.framework, 'call_save_b'));
|
|
429
|
+
assert.strictEqual(b[0].sha256, sha(GIF));
|
|
430
|
+
assert.strictEqual(b[0].ref, refOf(h, 2));
|
|
431
|
+
const a = saved(await waitForToolResult(h.framework, 'call_save_a'));
|
|
432
|
+
assert.strictEqual(a[0].sha256, sha(PNG));
|
|
433
|
+
assert.strictEqual(a[0].ref, refOf(h, 1));
|
|
434
|
+
}
|
|
435
|
+
finally {
|
|
436
|
+
await h.framework.stop();
|
|
437
|
+
rmSync(h.tempDir, { recursive: true, force: true });
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
it('a save dispatched in the SAME batch as the snapshot waits for it and saves it', async () => {
|
|
441
|
+
// One tool_use round: [snap, save]. Module calls are only enqueued while
|
|
442
|
+
// the save runs at once — without the barrier the inventory is empty at
|
|
443
|
+
// scan time and the older GIF gets saved under the snapshot's name.
|
|
444
|
+
const h = await startTurn({
|
|
445
|
+
prefix: 'sri-same-batch-',
|
|
446
|
+
world: (world) => { world.snapDelayMs = 150; },
|
|
447
|
+
responses: [
|
|
448
|
+
[snapCall('call_snap'), saveCall('call_save', { path: 'files/batched.png', index: 0 })],
|
|
449
|
+
done,
|
|
450
|
+
],
|
|
451
|
+
});
|
|
452
|
+
try {
|
|
453
|
+
const receipt = saved(await waitForToolResult(h.framework, 'call_save'));
|
|
454
|
+
assert.strictEqual(receipt[0].source, 'tool-result');
|
|
455
|
+
assert.strictEqual(receipt[0].toolCallId, 'call_snap');
|
|
456
|
+
assert.strictEqual(receipt[0].sha256, sha(PNG));
|
|
457
|
+
assert.ok((await fileBytes(h, 'files/batched.png'))?.equals(PNG));
|
|
458
|
+
// Early retention and commit-time serialization agree on the ref.
|
|
459
|
+
const snap = await waitForToolResult(h.framework, 'call_snap');
|
|
460
|
+
assert.match(snap.content, new RegExp(`ref ${receipt[0].ref}\\]`));
|
|
461
|
+
}
|
|
462
|
+
finally {
|
|
463
|
+
await h.framework.stop();
|
|
464
|
+
rmSync(h.tempDir, { recursive: true, force: true });
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
it('same-batch siblings are classified like the commit path: withheld data is no slot, a uri image is a reference slot', async () => {
|
|
468
|
+
const h = await startTurn({
|
|
469
|
+
prefix: 'sri-same-batch-class-',
|
|
470
|
+
responses: [
|
|
471
|
+
[
|
|
472
|
+
{ type: 'tool_use', id: 'call_contra', name: 'world--snap_contradiction', input: {} },
|
|
473
|
+
saveCall('call_save_contra', { path: 'files/contra.png', index: 0 }),
|
|
474
|
+
],
|
|
475
|
+
[
|
|
476
|
+
{ type: 'tool_use', id: 'call_uri', name: 'world--snap_uri', input: {} },
|
|
477
|
+
saveCall('call_save_uri', { path: 'files/uri.png', index: 0 }),
|
|
478
|
+
],
|
|
479
|
+
done,
|
|
480
|
+
],
|
|
481
|
+
});
|
|
482
|
+
try {
|
|
483
|
+
// The withheld image is not a slot in either representation: index 0
|
|
484
|
+
// is the GIF attachment, and its bytes never entered the ledger.
|
|
485
|
+
const contra = saved(await waitForToolResult(h.framework, 'call_save_contra'));
|
|
486
|
+
assert.strictEqual(contra[0].source, 'attachment');
|
|
487
|
+
assert.strictEqual(contra[0].sha256, sha(GIF));
|
|
488
|
+
assert.strictEqual(h.ledger.size, 0, 'withheld bytes must not be retained');
|
|
489
|
+
const stored = await waitForToolResult(h.framework, 'call_contra');
|
|
490
|
+
assert.strictEqual(parseImagePlaceholders(stored.content).length, 0);
|
|
491
|
+
const uri = await waitForToolResult(h.framework, 'call_save_uri');
|
|
492
|
+
assert.strictEqual(uri.isError, true);
|
|
493
|
+
assert.match(uri.content, /index 0 is a reference \(ref_[0-9a-z]+_[0-9a-z]+, image\/png, from world--snap_uri \(call call_uri\)\)/);
|
|
494
|
+
assert.strictEqual(await fileBytes(h, 'files/uri.png'), null);
|
|
495
|
+
}
|
|
496
|
+
finally {
|
|
497
|
+
await h.framework.stop();
|
|
498
|
+
rmSync(h.tempDir, { recursive: true, force: true });
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
it('a placeholder from a previous process (other nonce) is a defined miss by index AND by ref', async () => {
|
|
502
|
+
const h = await startTurn({
|
|
503
|
+
prefix: 'sri-stale-',
|
|
504
|
+
responses: [
|
|
505
|
+
[saveCall('call_save_idx', { path: 'files/stale.png', index: 0 })],
|
|
506
|
+
[saveCall('call_save_ref', { path: 'files/stale-ref.png', ref: 'img_zzzzzz_7' })],
|
|
507
|
+
done,
|
|
508
|
+
],
|
|
509
|
+
seedHistory: (framework) => {
|
|
510
|
+
framework.getAgent('prime').getContextManager().addMessage('user', [{
|
|
511
|
+
type: 'tool_result',
|
|
512
|
+
toolUseId: 'call_yesterday',
|
|
513
|
+
toolName: 'world--snap',
|
|
514
|
+
content: 'yesterday\n[image: image/png, ~691KB, ref img_zzzzzz_7]',
|
|
515
|
+
isError: false,
|
|
516
|
+
}]);
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
try {
|
|
520
|
+
// Seven fresh images in this process: a bare `img_7` would collide.
|
|
521
|
+
for (let i = 1; i <= 7; i++) {
|
|
522
|
+
h.ledger.retain({ toolCallId: `call_today_${i}`, toolName: 'world--snap', blockIndex: 0, data: PNG.toString('base64'), mediaType: 'image/png' });
|
|
523
|
+
}
|
|
524
|
+
const byIndex = await waitForToolResult(h.framework, 'call_save_idx');
|
|
525
|
+
assert.strictEqual(byIndex.isError, true);
|
|
526
|
+
assert.match(byIndex.content, /index 0 \(img_zzzzzz_7, from world--snap \(call call_yesterday\)\) is no longer retained — this process has no record/);
|
|
527
|
+
const byRef = await waitForToolResult(h.framework, 'call_save_ref');
|
|
528
|
+
assert.strictEqual(byRef.isError, true);
|
|
529
|
+
// The stale placeholder IS visible, so the ref resolves through it and fails the same way.
|
|
530
|
+
assert.match(byRef.content, /img_zzzzzz_7, from world--snap \(call call_yesterday\)\) is no longer retained — this process has no record/);
|
|
531
|
+
assert.strictEqual(await fileBytes(h, 'files/stale.png'), null);
|
|
532
|
+
assert.strictEqual(await fileBytes(h, 'files/stale-ref.png'), null);
|
|
533
|
+
}
|
|
534
|
+
finally {
|
|
535
|
+
await h.framework.stop();
|
|
536
|
+
rmSync(h.tempDir, { recursive: true, force: true });
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
it('a direct `ref` is saved through its placeholder in context — a retained ref that is not visible is refused', async () => {
|
|
540
|
+
const ledger = new ToolImageLedger();
|
|
541
|
+
const ghost = ledger.retain({ toolCallId: 'call_ghost', toolName: 'world--snap', blockIndex: 1, data: PNG.toString('base64'), mediaType: 'image/png' });
|
|
542
|
+
const h = await startTurn({
|
|
543
|
+
prefix: 'sri-ref-context-',
|
|
544
|
+
ledger,
|
|
545
|
+
responses: [
|
|
546
|
+
[saveCall('call_save', { path: 'files/ghost.png', ref: ghost.ref })],
|
|
547
|
+
done,
|
|
548
|
+
],
|
|
549
|
+
});
|
|
550
|
+
try {
|
|
551
|
+
const result = await waitForToolResult(h.framework, 'call_save');
|
|
552
|
+
assert.strictEqual(result.isError, true);
|
|
553
|
+
assert.match(result.content, new RegExp(`ref ${ghost.ref} \\(from world--snap \\(call call_ghost\\)\\) does not appear in your recent context`));
|
|
554
|
+
assert.strictEqual(await fileBytes(h, 'files/ghost.png'), null);
|
|
555
|
+
}
|
|
556
|
+
finally {
|
|
557
|
+
await h.framework.stop();
|
|
558
|
+
rmSync(h.tempDir, { recursive: true, force: true });
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
describe('ToolImageLedger', () => {
|
|
563
|
+
it('mints one ref per (call, block), idempotently, with digest provenance', () => {
|
|
564
|
+
const ledger = new ToolImageLedger();
|
|
565
|
+
const a = ledger.retain({ toolCallId: 'c1', toolName: 't', blockIndex: 1, data: PNG.toString('base64'), mediaType: 'image/png' });
|
|
566
|
+
const again = ledger.retain({ toolCallId: 'c1', toolName: 't', blockIndex: 1, data: PNG.toString('base64'), mediaType: 'image/png' });
|
|
567
|
+
const b = ledger.retain({ toolCallId: 'c1', toolName: 't', blockIndex: 3, data: GIF.toString('base64'), mediaType: 'image/gif' });
|
|
568
|
+
assert.match(a.ref, REF_1);
|
|
569
|
+
assert.strictEqual(a.ref, `img_${ledger.nonce}_1`);
|
|
570
|
+
assert.ok(TOOL_IMAGE_REF_RE.test(a.ref));
|
|
571
|
+
assert.strictEqual(again.ref, a.ref);
|
|
572
|
+
assert.strictEqual(b.ref, `img_${ledger.nonce}_2`);
|
|
573
|
+
assert.strictEqual(a.sha256, sha(PNG));
|
|
574
|
+
assert.strictEqual(a.byteSize, PNG.byteLength);
|
|
575
|
+
assert.strictEqual(ledger.refFor('c1', 3), b.ref);
|
|
576
|
+
const hit = ledger.lookup(a.ref);
|
|
577
|
+
assert.strictEqual(hit.status, 'retained');
|
|
578
|
+
assert.strictEqual(hit.status === 'retained' ? hit.image.data : null, PNG.toString('base64'));
|
|
579
|
+
assert.strictEqual(ledger.lookup(`img_${ledger.nonce}_9`).status, 'unknown');
|
|
580
|
+
// Another ledger is another namespace: the same sequence is a stranger.
|
|
581
|
+
const other = new ToolImageLedger();
|
|
582
|
+
assert.notStrictEqual(other.nonce, ledger.nonce);
|
|
583
|
+
assert.strictEqual(other.lookup(a.ref).status, 'unknown');
|
|
584
|
+
assert.strictEqual(ledger.lookup('img_1').status, 'unknown');
|
|
585
|
+
});
|
|
586
|
+
it('digests large payloads chunked with the same result as a whole decode', () => {
|
|
587
|
+
const big = Buffer.alloc(1_500_000);
|
|
588
|
+
for (let i = 0; i < big.length; i++)
|
|
589
|
+
big[i] = (i * 7919) & 0xff;
|
|
590
|
+
const ledger = new ToolImageLedger();
|
|
591
|
+
const kept = ledger.retain({ toolCallId: 'c', toolName: 't', blockIndex: 0, data: big.toString('base64'), mediaType: 'image/png' });
|
|
592
|
+
assert.strictEqual(kept.byteSize, big.byteLength);
|
|
593
|
+
assert.strictEqual(kept.sha256, sha(big));
|
|
594
|
+
// Whitespace-bearing base64 (legal for decoders) falls back to a whole decode — same answer.
|
|
595
|
+
const wrapped = big.toString('base64').replace(/(.{76})/g, '$1\n');
|
|
596
|
+
const same = ledger.retain({ toolCallId: 'c2', toolName: 't', blockIndex: 0, data: wrapped, mediaType: 'image/png' });
|
|
597
|
+
assert.strictEqual(same.byteSize, Buffer.from(wrapped, 'base64').byteLength);
|
|
598
|
+
assert.strictEqual(same.sha256, sha(Buffer.from(wrapped, 'base64')));
|
|
599
|
+
});
|
|
600
|
+
it('evicts oldest-first over the budget but keeps provenance for the error', () => {
|
|
601
|
+
const ledger = new ToolImageLedger({ maxEntries: 2 });
|
|
602
|
+
for (let i = 0; i < 3; i++) {
|
|
603
|
+
ledger.retain({ toolCallId: `c${i}`, toolName: 'snap', blockIndex: 0, data: PNG.toString('base64'), mediaType: 'image/png' });
|
|
604
|
+
}
|
|
605
|
+
assert.strictEqual(ledger.size, 2);
|
|
606
|
+
const evicted = ledger.lookup(`img_${ledger.nonce}_1`);
|
|
607
|
+
assert.strictEqual(evicted.status, 'evicted');
|
|
608
|
+
assert.strictEqual(evicted.status === 'evicted' ? evicted.image.toolCallId : null, 'c0');
|
|
609
|
+
assert.strictEqual(ledger.lookup(`img_${ledger.nonce}_2`).status, 'retained');
|
|
610
|
+
assert.strictEqual(ledger.lookup(`img_${ledger.nonce}_3`).status, 'retained');
|
|
611
|
+
});
|
|
612
|
+
it('placeholders round-trip through the serializer, with and without a ref', () => {
|
|
613
|
+
const data = [
|
|
614
|
+
{ type: 'text', text: 'two frames' },
|
|
615
|
+
{ type: 'image', data: PNG.toString('base64'), mimeType: 'image/png' },
|
|
616
|
+
{ type: 'image', data: GIF.toString('base64'), mimeType: 'image/gif' },
|
|
617
|
+
];
|
|
618
|
+
const legacy = toolResultDataToHistoryString(data);
|
|
619
|
+
const refsOf = (text) => parseImagePlaceholders(text).map((p) => (p.kind === 'inline' ? p.ref : p.refId));
|
|
620
|
+
assert.deepStrictEqual(parseImagePlaceholders(legacy).map((p) => [p.mediaType, p.kind === 'inline' ? p.ref : 'ref']), [
|
|
621
|
+
['image/png', null],
|
|
622
|
+
['image/gif', null],
|
|
623
|
+
]);
|
|
624
|
+
const seen = [];
|
|
625
|
+
const withRefs = toolResultDataToHistoryString(data, undefined, {
|
|
626
|
+
imageRef: (blockIndex) => { seen.push(blockIndex); return `img_abc123_${blockIndex}`; },
|
|
627
|
+
});
|
|
628
|
+
assert.deepStrictEqual(seen, [1, 2], 'block indices are positions in the content array');
|
|
629
|
+
// Size label is the serializer's base64-derived estimate, not the exact byte count.
|
|
630
|
+
assert.match(withRefs, /\[image: image\/png, \d+B, ref img_abc123_1\]/);
|
|
631
|
+
assert.strictEqual(formatToolImagePlaceholder('image/png', '~691KB', 'img_k7x3q2_7'), '[image: image/png, ~691KB, ref img_k7x3q2_7]');
|
|
632
|
+
assert.deepStrictEqual(refsOf(withRefs), ['img_abc123_1', 'img_abc123_2']);
|
|
633
|
+
assert.ok(!withRefs.includes(PNG.toString('base64')));
|
|
634
|
+
});
|
|
635
|
+
it('an uppercase mime from a tool still round-trips (normalized at retain and format time)', () => {
|
|
636
|
+
const ledger = new ToolImageLedger();
|
|
637
|
+
const retained = ledger.retain({ toolCallId: 'c', toolName: 't', blockIndex: 0, data: PNG.toString('base64'), mediaType: 'Image/PNG ' });
|
|
638
|
+
assert.strictEqual(retained.mediaType, 'image/png');
|
|
639
|
+
const text = toolResultDataToHistoryString([{ type: 'image', data: PNG.toString('base64'), mimeType: 'Image/PNG' }], undefined, { imageRef: () => retained.ref });
|
|
640
|
+
const parsed = parseImagePlaceholders(text);
|
|
641
|
+
assert.strictEqual(parsed.length, 1, `placeholder must re-parse: ${text}`);
|
|
642
|
+
assert.strictEqual(parsed[0].kind === 'inline' ? parsed[0].ref : null, retained.ref);
|
|
643
|
+
assert.strictEqual(parsed[0].mediaType, 'image/png');
|
|
644
|
+
});
|
|
645
|
+
it('any mime a tool sends normalizes to a type/subtype essence that round-trips', () => {
|
|
646
|
+
assert.strictEqual(normalizeMediaType('image/png; charset=binary'), 'image/png');
|
|
647
|
+
assert.strictEqual(normalizeMediaType(' IMAGE/JPEG '), 'image/jpeg');
|
|
648
|
+
assert.strictEqual(normalizeMediaType('image/x_custom'), 'image/x_custom');
|
|
649
|
+
assert.strictEqual(normalizeMediaType('image/svg+xml'), 'image/svg+xml');
|
|
650
|
+
assert.strictEqual(normalizeMediaType('garbage'), FALLBACK_MEDIA_TYPE);
|
|
651
|
+
assert.strictEqual(normalizeMediaType('image/has space'), FALLBACK_MEDIA_TYPE);
|
|
652
|
+
assert.strictEqual(normalizeMediaType(''), FALLBACK_MEDIA_TYPE);
|
|
653
|
+
// The invariant the formatter relies on: whatever comes in, the
|
|
654
|
+
// placeholder written for it re-parses to the normalized mime.
|
|
655
|
+
for (const raw of ['image/png; charset=binary', 'Image/PNG', 'image/x_custom', 'garbage', 'image/a,b', 'image/]', 'text/plain;x=1;y=2']) {
|
|
656
|
+
const mime = normalizeMediaType(raw);
|
|
657
|
+
const parsed = parseImagePlaceholders(formatToolImagePlaceholder(raw, '~1KB', 'img_abc123_1'));
|
|
658
|
+
assert.strictEqual(parsed.length, 1, `must re-parse: ${raw}`);
|
|
659
|
+
assert.strictEqual(parsed[0].mediaType, mime, raw);
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
it('splitting for truncation keeps every slot at or past the cut, and never leaves a partial one in the head', () => {
|
|
663
|
+
const png = '[image: image/png, ~12KB, ref img_abc123_1]';
|
|
664
|
+
const stub = '[ref_1_ab3d] cam.png — image/jpeg, 1.2MB claimed — from tool result — fetch with fetch_reference';
|
|
665
|
+
const text = `head text\n${png}\nmiddle text that is long enough\n${stub}\ntail\n[image: image/gif, 4B, ref img_abc123_2]`;
|
|
666
|
+
// Cut exactly at the start of the middle text: png stays, stub + gif move.
|
|
667
|
+
const cut1 = text.indexOf('middle');
|
|
668
|
+
const a = splitPreservingImageSlots(text, cut1);
|
|
669
|
+
assert.strictEqual(a.head, text.slice(0, cut1));
|
|
670
|
+
assert.deepStrictEqual(a.tail, [stub, '[image: image/gif, 4B, ref img_abc123_2]']);
|
|
671
|
+
// Cut in the middle of the stub line: the cut moves back to the stub's start.
|
|
672
|
+
const cut2 = text.indexOf('1.2MB');
|
|
673
|
+
const b = splitPreservingImageSlots(text, cut2);
|
|
674
|
+
assert.strictEqual(b.head, text.slice(0, text.indexOf(stub)));
|
|
675
|
+
assert.deepStrictEqual(b.tail, [stub, '[image: image/gif, 4B, ref img_abc123_2]']);
|
|
676
|
+
// Cut in the middle of the png placeholder.
|
|
677
|
+
const cut3 = text.indexOf('~12KB');
|
|
678
|
+
const c = splitPreservingImageSlots(text, cut3);
|
|
679
|
+
assert.strictEqual(c.head, 'head text\n');
|
|
680
|
+
assert.strictEqual(c.tail.length, 3);
|
|
681
|
+
// Head + preserved tail parse to exactly the original slots, in order.
|
|
682
|
+
for (const cut of [cut1, cut2, cut3, 1, text.length - 3]) {
|
|
683
|
+
const { head, tail } = splitPreservingImageSlots(text, cut);
|
|
684
|
+
const rejoined = head + '\n\n[truncated — original was N chars]' + formatPreservedImageSlots(tail);
|
|
685
|
+
assert.deepStrictEqual(parseImagePlaceholders(rejoined).map((p) => p.text), parseImagePlaceholders(text).map((p) => p.text), `cut at ${cut}`);
|
|
686
|
+
}
|
|
687
|
+
// The generic history truncation uses the same rule.
|
|
688
|
+
const truncated = truncateForHistory(text, cut3);
|
|
689
|
+
assert.strictEqual(parseImagePlaceholders(truncated).length, 3);
|
|
690
|
+
assert.match(truncated, /\[truncated — original was \d+ chars\]/);
|
|
691
|
+
});
|
|
692
|
+
it('parses image-typed reference stubs as slots, in text order with inline placeholders', () => {
|
|
693
|
+
const text = [
|
|
694
|
+
'frame one',
|
|
695
|
+
'[image: image/png, ~12KB, ref img_abc123_3]',
|
|
696
|
+
'[ref_1_ab3d] cam.png — image/jpeg, 1.2MB claimed — from tool result — fetch with fetch_reference',
|
|
697
|
+
'[ref_2_zz9q] chord.wav — audio/wav, ~4.0MB claimed — from tool result — fetch with fetch_reference',
|
|
698
|
+
].join('\n');
|
|
699
|
+
const slots = parseImagePlaceholders(text);
|
|
700
|
+
assert.deepStrictEqual(slots.map((s) => (s.kind === 'inline' ? ['inline', s.ref, s.mediaType] : ['reference', s.refId, s.mediaType])), [
|
|
701
|
+
['inline', 'img_abc123_3', 'image/png'],
|
|
702
|
+
['reference', 'ref_1_ab3d', 'image/jpeg'],
|
|
703
|
+
['reference', 'ref_2_zz9q', null],
|
|
704
|
+
]);
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
//# sourceMappingURL=save-recent-image.test.js.map
|