refactor(agent-core-v2): replace blob selector with ModelDef.blobs codec

- introduce ModelBlobCodec ({ dehydrate, rehydrate }) declared on ModelDef.blobs; the model owns record/state traversal, WireService owns storage I/O
- remove WireBlobSelector / WireBlobTarget and the contextBlobSelector seed; declare blob handling inline on ContextModel.blobs
- rename IAgentBlobService.rehydrateParts -> loadParts and PartsRehydrator -> PartsTransformer (same shape for both directions)
- WireService.appendToWireLog routes dehydrate through the per-record model codec instead of a construction-time selector
This commit is contained in:
haozhe.yang 2026-07-07 10:36:55 +08:00
parent b0fafa5f5c
commit 40cbc1a7f4
13 changed files with 170 additions and 187 deletions

View file

@ -2,7 +2,7 @@
* `blob` domain `IAgentBlobService` contract.
*
* Offloads large inline media payloads to content-addressed blob storage and
* rehydrates them on read. Bound at Agent scope.
* loads them back on read. Bound at Agent scope.
*/
import type { ContentPart } from '#/app/llmProtocol';
@ -16,7 +16,7 @@ export interface IAgentBlobService {
readonly _serviceBrand: undefined;
offloadParts(parts: readonly ContentPart[]): Promise<readonly ContentPart[]>;
rehydrateParts(parts: readonly ContentPart[]): Promise<readonly ContentPart[]>;
loadParts(parts: readonly ContentPart[]): Promise<readonly ContentPart[]>;
isBlobRef(url: string): boolean;
}

View file

@ -2,7 +2,7 @@
* `blob` domain `IAgentBlobService` implementation.
*
* Offloads large inline media payloads into content-addressed blobs and
* rehydrates them on read; persists bytes through `IBlobStore` under the
* loads them back on read; persists bytes through `IBlobStore` under the
* agent's `scope('blobs')` root, matching the v1 `<agentDir>/blobs/<sha256>`
* layout. Bound at Agent scope.
*/
@ -61,11 +61,11 @@ export class AgentBlobServiceImpl implements IAgentBlobService {
return changed ? out : parts;
}
async rehydrateParts(parts: readonly ContentPart[]): Promise<readonly ContentPart[]> {
async loadParts(parts: readonly ContentPart[]): Promise<readonly ContentPart[]> {
let changed = false;
const out: ContentPart[] = [];
for (const part of parts) {
const next = await this.rehydrateContentPart(part);
const next = await this.loadContentPart(part);
if (next !== part) changed = true;
out.push(next);
}
@ -90,7 +90,7 @@ export class AgentBlobServiceImpl implements IAgentBlobService {
return updated === undefined ? part : (updated as unknown as ContentPart);
}
private async rehydrateContentPart(part: ContentPart): Promise<ContentPart> {
private async loadContentPart(part: ContentPart): Promise<ContentPart> {
let updated: Record<string, unknown> | undefined;
for (const [key, value] of Object.entries(part)) {
const mediaObj = asMediaContainer(value);
@ -99,14 +99,14 @@ export class AgentBlobServiceImpl implements IAgentBlobService {
const url = mediaObj.url;
if (typeof url !== 'string' || !this.isBlobRef(url)) continue;
const newUrl = await this.rehydrateBlobRefUrl(url);
const newUrl = await this.loadBlobRefUrl(url);
if (updated === undefined) updated = { ...part };
updated[key] = { ...(value as object), url: newUrl ?? MISSING_MEDIA_PLACEHOLDER };
}
return updated === undefined ? part : (updated as unknown as ContentPart);
}
private async rehydrateBlobRefUrl(url: string): Promise<string | undefined> {
private async loadBlobRefUrl(url: string): Promise<string | undefined> {
const rest = url.slice(BLOBREF_PROTOCOL.length);
const semiIdx = rest.indexOf(';');
if (semiIdx === -1) return undefined;

View file

@ -9,8 +9,8 @@
* the Model silently and never invokes these methods), so existing subscribers
* (micro-compaction, context-injector, task-notification) observe the same
* splice-shaped change events regardless of which 1.4 Op was persisted. Message
* ids are stamped at the dispatch call site so `apply` stays pure. Blob offload
* lives in the `WireService` hook seeded with `contextBlobSelector`. Bound at
* ids are stamped at the dispatch call site so `apply` stays pure. Blob
* dehydrate/rehydrate is declared on `ContextModel.blobs`. Bound at
* Agent scope.
*/

View file

@ -3,8 +3,7 @@
* 1.4 Ops `context.append_message` (`contextAppendMessage`) / `context.clear`
* (`contextClear`) / `context.apply_compaction` (`contextApplyCompaction`) /
* `context.undo` (`contextUndo`) for the per-agent conversation history, plus the
* legacy `context.splice` (`contextSplice`) Op and the `contextBlobSelector` that
* drives blob offload for persisted message parts.
* legacy `context.splice` (`contextSplice`) Op.
*
* Declares the history as `ContextMessage[]` (initial `[]`); every Op's `apply`
* is a pure array transform that returns a NEW reference on change and the SAME
@ -23,18 +22,18 @@
* still replay (newer-version passthrough, no migration) and for the few internal
* single-delete mutations that have no 1.4 spelling.
*
* Blob handling uses two complementary mechanisms:
* - `contextBlobSelector` (record-level): offloads oversized content parts to
* blob storage on append, replacing data URIs with `blobref:` references.
* - `ContextModel.rehydrate` (model-level): after replay, traverses the
* surviving final state and rehydrates `blobref:` URLs back to inline data
* URIs skipping I/O for data that was compacted away during the session.
*
* The selector is seeded into the Agent wire by `agentLifecycle`.
* Blob handling is declared as a `ModelBlobCodec` on `ContextModel.blobs`:
* - `dehydrate(record, transform)`: at dispatch time, traverses message content
* in `context.splice` and `context.append_message` records, passing each
* `ContentPart[]` through `transform` to offload oversized data URIs.
* - `rehydrate(state, transform)`: after replay, traverses the surviving final
* state and loads `blobref:` URLs back to inline data skipping I/O for
* data that was compacted away during the session.
*/
import type { ContentPart } from '#/app/llmProtocol';
import { defineModel, defineOp, type WireBlobSelector } from '#/wire';
import { defineModel, defineOp, type PartsTransformer } from '#/wire';
import type { PersistedRecord } from '#/wire';
import {
foldAppendMessage,
@ -44,20 +43,51 @@ import {
} from './loopEventFold';
import type { ContextMessage } from './types';
export const ContextModel = defineModel<ContextMessage[]>('contextMemory', () => [], {
rehydrate: async (state, rehydrateParts) => {
let changed = false;
const result: ContextMessage[] = [];
for (const msg of state) {
const parts = await rehydrateParts(msg.content);
if (parts !== msg.content) {
changed = true;
result.push({ ...msg, content: [...parts] as ContentPart[] });
} else {
result.push(msg);
}
async function dehydrateMessages(
messages: readonly ContextMessage[],
transform: PartsTransformer,
): Promise<{ changed: boolean; result: ContextMessage[] }> {
let changed = false;
const result: ContextMessage[] = [];
for (const msg of messages) {
const parts = await transform(msg.content);
if (parts !== msg.content) {
changed = true;
result.push({ ...msg, content: [...parts] as ContentPart[] });
} else {
result.push(msg);
}
return changed ? result : state;
}
return { changed, result };
}
async function dehydrateRecord(
record: PersistedRecord,
transform: PartsTransformer,
): Promise<PersistedRecord> {
if (record.type === 'context.splice') {
const messages = record['messages'];
if (!Array.isArray(messages)) return record;
const { changed, result } = await dehydrateMessages(messages as ContextMessage[], transform);
return changed ? { ...record, messages: result } : record;
}
if (record.type === 'context.append_message') {
const message = record['message'] as ContextMessage | undefined;
if (message === undefined) return record;
const parts = await transform(message.content);
if (parts === message.content) return record;
return { ...record, message: { ...message, content: [...parts] } };
}
return record;
}
export const ContextModel = defineModel<ContextMessage[]>('contextMemory', () => [], {
blobs: {
dehydrate: dehydrateRecord,
rehydrate: async (state, transform) => {
const { changed, result } = await dehydrateMessages(state, transform);
return changed ? result : state;
},
},
});
@ -174,33 +204,3 @@ function isRealUserPrompt(message: ContextMessage): boolean {
origin.trigger === 'user-slash'
);
}
export const contextBlobSelector: WireBlobSelector = (record) => {
if (record.type === 'context.splice') {
const messages = record['messages'];
if (!Array.isArray(messages)) return [];
return (messages as readonly ContextMessage[]).map((message, index) => ({
parts: message.content,
replace: (current, parts) => ({
...current,
messages: (current['messages'] as readonly ContextMessage[]).map((item, itemIndex) =>
itemIndex === index ? { ...item, content: [...parts] } : item,
),
}),
}));
}
if (record.type === 'context.append_message') {
const message = record['message'] as ContextMessage | undefined;
if (message === undefined) return [];
return [
{
parts: message.content,
replace: (current, parts) => ({
...current,
message: { ...(current['message'] as ContextMessage), content: [...parts] },
}),
},
];
}
return [];
};

View file

@ -257,7 +257,7 @@ export class AgentWireRecordService extends Disposable implements IAgentWireReco
let current = record;
for (const selector of [...selectors] as BlobSelector<T>[]) {
for (const target of selector(current)) {
const parts = await blobStore.rehydrateParts(target.parts);
const parts = await blobStore.loadParts(target.parts);
if (parts !== target.parts) {
current = target.replace(current, parts);
}

View file

@ -42,7 +42,7 @@ import { ISessionMetadata } from '#/session/sessionMetadata';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
import { IAgentScopeContext } from '#/agent/scopeContext';
import { IAgentProfileService } from '#/agent/profile';
import { contextBlobSelector, IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentBuiltinToolsRegistrar } from '#/agent/toolRegistry';
import {
AGENT_WIRE_PROTOCOL_VERSION,
@ -141,7 +141,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
} satisfies IAgentScopeContext,
],
[IAgentWireRecordService, new SyncDescriptor(AgentWireRecordService, [{ homedir: agentHomedir }])],
[IAgentWireService, new SyncDescriptor(WireService, [{ logScope: agentScope, logKey: WIRE_RECORD_FILENAME, blobSelector: contextBlobSelector }])],
[IAgentWireService, new SyncDescriptor(WireService, [{ logScope: agentScope, logKey: WIRE_RECORD_FILENAME }])],
[IAgentBlobService, new SyncDescriptor(AgentBlobServiceImpl)],
[
IAgentMcpService,

View file

@ -1,23 +1,30 @@
/**
* `wire` domain (L2) Model definition primitive (`ModelDef` / `defineModel`),
* `DeepReadonly<T>` (the compile-time half of immutability), and the
* `ModelRehydrateFn` / `PartsRehydrator` types that let a model declare how to
* rehydrate blob references in its state after replay.
* `ModelBlobCodec` / `PartsTransformer` types that let a model declare how to
* dehydrate large inline media before persistence and rehydrate blob references
* in its state after replay.
*
* A `ModelDef` is a stateless descriptor: it names a model and manufactures its
* initial state via `initial`. It never holds state itself per-scope state
* instances are owned by `IWireService`, and domain services read them through
* `wire.getModel(model)`. The optional `rehydrate` function declares how to
* traverse the model's state and replace blob references with inline data after
* replay only models whose state contains `ContentPart[]` (e.g. ContextModel)
* need it; all others leave it undefined (no-op). `WireService.replay` applies
* all records first (blobref URLs enter the model state as-is, zero I/O), then
* calls `rehydrate` on each model that declares it so only the *surviving*
* state is rehydrated, skipping data that was later removed by compaction.
* `wire.getModel(model)`. The optional `blobs` codec declares both directions
* of the blob offload pipeline:
* - `dehydrate(record, transform)`: called per-record at dispatch time; the
* model traverses its record structure, passes each `ContentPart[]` through
* `transform` (which offloads oversized data URIs to blob storage and returns
* parts with `blobref:` URLs), and returns the transformed record.
* - `rehydrate(state, transform)`: called once after replay; the model
* traverses the surviving final state, passes each `ContentPart[]` through
* `transform` (which loads blob references back to inline data URIs), and
* returns the transformed state. Only the *surviving* state is rehydrated,
* skipping data that was later removed by compaction.
*
* `PartsRehydrator` uses `readonly unknown[]` rather than `ContentPart[]` so
* Both directions receive a `PartsTransformer` the same function shape so
* the model owns the traversal logic and `WireService` owns the storage I/O.
* `PartsTransformer` uses `readonly unknown[]` rather than `ContentPart[]` so
* this file stays free of `app/llmProtocol` imports (L2 L3 boundary); the
* cast happens once inside `WireService.rehydrateModels`.
* cast happens once inside `WireService`.
*
* `DeepReadonly<T>` recursively maps a state type to its deeply-readonly view
* for the references returned by `getModel` / `subscribe`: functions pass
@ -27,17 +34,19 @@
* applied by `WireService` after every `apply`. Scope-agnostic.
*/
export type PartsRehydrator = (parts: readonly unknown[]) => Promise<readonly unknown[]>;
import type { PersistedRecord } from './wireService';
export type ModelRehydrateFn<S> = (
state: S,
rehydrateParts: PartsRehydrator,
) => S | Promise<S>;
export type PartsTransformer = (parts: readonly unknown[]) => Promise<readonly unknown[]>;
export interface ModelBlobCodec<S> {
dehydrate(record: PersistedRecord, transform: PartsTransformer): PersistedRecord | Promise<PersistedRecord>;
rehydrate(state: S, transform: PartsTransformer): S | Promise<S>;
}
export interface ModelDef<S> {
readonly name: string;
readonly initial: () => S;
readonly rehydrate?: ModelRehydrateFn<S>;
readonly blobs?: ModelBlobCodec<S>;
}
export interface DerivedModelDef<S> {
@ -45,24 +54,24 @@ export interface DerivedModelDef<S> {
readonly initial: () => S;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly reducers: Readonly<Record<string, (state: S, payload: any) => S>>;
readonly rehydrate?: ModelRehydrateFn<S>;
readonly blobs?: ModelBlobCodec<S>;
}
export function defineModel<S>(
name: string,
initial: () => S,
opts?: { rehydrate?: ModelRehydrateFn<S> },
opts?: { blobs?: ModelBlobCodec<S> },
): ModelDef<S> {
return { name, initial, rehydrate: opts?.rehydrate };
return { name, initial, blobs: opts?.blobs };
}
export function defineDerivedModel<S>(
name: string,
initial: () => S,
reducers: Record<string, (state: S, payload: any) => S>,
opts?: { rehydrate?: ModelRehydrateFn<S> },
opts?: { blobs?: ModelBlobCodec<S> },
): DerivedModelDef<S> {
return { name, initial, reducers, rehydrate: opts?.rehydrate };
return { name, initial, reducers, blobs: opts?.blobs };
}

View file

@ -4,11 +4,11 @@
*
* The scope-agnostic state-machine engine: `dispatch` persists + applies +
* notifies (OpGroup `{ silent: false }`), `replay` (async rehydrates blob
* references first) applies only (`{ silent: true }`); `flush` drains the
* serialized persist queue. Reads go through `getModel` / `subscribe`; the live
* append-log record stream streams via `onEmission`, restore completion via
* `onRestored`, and Op-derived facts flow out through `IEventBus` (see `op.ts`
* `toEvent`). A single implementation serves every
* references via `ModelDef.blobs` first) applies only (`{ silent: true }`);
* `flush` drains the serialized persist queue. Reads go through `getModel` /
* `subscribe`; the live append-log record stream streams via `onEmission`,
* restore completion via `onRestored`, and Op-derived facts flow out through
* `IEventBus` (see `op.ts` `toEvent`). A single implementation serves every
* scope instances are isolated per scope through the distinct DI tokens in
* `tokens`, each seeded with its own persistence key. `PersistedRecord` is the
* on-the-wire append-log shape (`wire.jsonl`): intentionally flat

View file

@ -1,18 +1,16 @@
/**
* `wire` domain (L2) `WireService`, the single scope-agnostic implementation
* of `IWireService`, plus its construction options (`WireServiceOptions`), the
* optional blob offload/rehydrate seam (`WireBlobSelector` / `WireBlobTarget`),
* of `IWireService`, plus its construction options (`WireServiceOptions`)
* and the coded `CycleError`.
*
* One class serves every scope: per-scope isolation comes from the distinct DI
* tokens in `tokens`, each seeded with its own `WireServiceOptions`
* (`logScope` / `logKey`, and optionally a `blobSelector`) as the leading
* (non-service) constructor argument through a `SyncDescriptor`, mirroring
* `WireRecordServiceOptions`. `dispatch` and `replay` both lower to one
* primitive, `execute(OpGroup)` apply-all THEN onChange-all, so a subscriber
* never observes a partially-applied group with `dispatch` adding persistence
* + emission + Op-derived `IEventBus` events (`silent: false`) and `replay`
* staying silent (apply only, skipping
* (`logScope` / `logKey`) as the leading (non-service) constructor argument
* through a `SyncDescriptor`, mirroring `WireRecordServiceOptions`. `dispatch`
* and `replay` both lower to one primitive, `execute(OpGroup)` apply-all THEN
* onChange-all, so a subscriber never observes a partially-applied group with
* `dispatch` adding persistence + emission + Op-derived `IEventBus` events
* (`silent: false`) and `replay` staying silent (apply only, skipping
* unknown record types, then `onRestored`). A reentrancy guard (`dispatching` +
* `queue` + `drain`, capped by `MAX_DRAIN = 100`) lets onChange handlers enqueue
* further ops without reentering `execute`; a cascade past the cap throws
@ -28,19 +26,21 @@
* flat `{ type, ...payload }` record scalar / array payloads nested so a
* JSONL line stays an object, with `type` / `time` stripped back out on replay.
*
* Blob handling has two asymmetric paths:
* Blob handling is driven by each `ModelDef`'s optional `blobs` codec
* (`ModelBlobCodec`), which declares two symmetric directions:
*
* - **Offload (dispatch persist)**: record-level `WireBlobSelector` rewrites
* oversized inline parts to `blobref:` references before the record reaches
* the append log. `apply` and the live emission still see the original inline
* payload. Records with no offloadable targets short-circuit synchronously.
* - **Dehydrate (dispatch persist)**: `model.blobs.dehydrate(record, transform)`
* lets the model traverse its own record structure, pass each `ContentPart[]`
* through `transform` (which offloads oversized inline data to blob storage),
* and return the transformed record. `apply` and the live emission still see
* the original inline payload. Records whose model has no `blobs` codec
* short-circuit synchronously (no queue, no microtask).
*
* - **Rehydrate (replay model)**: `replay` applies all records first with
* blobref URLs entering the model state as-is (zero I/O). After all records
* are applied, `rehydrateModels` calls `ModelDef.rehydrate` on each model
* that declares it, replacing blobref URLs with inline data *only* in the
* surviving final state. This skips I/O for data later removed by compaction
* a 20×+ speedup for long sessions with many images.
* - **Rehydrate (replay model)**: after all records are applied,
* `rehydrateModels` calls `model.blobs.rehydrate(state, transform)` on each
* model that declares a `blobs` codec, replacing blobref URLs with inline data
* *only* in the surviving final state skipping I/O for data later removed by
* compaction (a 20×+ speedup for long sessions with many images).
*
* Scope-agnostic.
*/
@ -53,7 +53,7 @@ import { type DomainEvent, IEventBus } from '#/app/event';
import type { ContentPart } from '#/app/llmProtocol';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
import type { DeepReadonly, DerivedModelDef, ModelDef, PartsRehydrator } from './model';
import type { DeepReadonly, DerivedModelDef, ModelDef, PartsTransformer } from './model';
import type { Op } from './op';
import { OP_REGISTRY } from './op';
import type {
@ -75,17 +75,9 @@ export class CycleError extends Error {
}
}
export interface WireBlobTarget {
readonly parts: readonly ContentPart[];
replace(record: PersistedRecord, parts: readonly ContentPart[]): PersistedRecord;
}
export type WireBlobSelector = (record: PersistedRecord) => Iterable<WireBlobTarget>;
export interface WireServiceOptions {
readonly logScope: string;
readonly logKey: string;
readonly blobSelector?: WireBlobSelector;
}
interface ModelInstance {
@ -238,7 +230,7 @@ export class WireService extends Disposable implements IWireService {
inst.state = Object.freeze(op.descriptor.apply(prev, op.payload));
if (!group.silent) {
const record = this.toRecord(op);
this.appendToWireLog(record);
this.appendToWireLog(record, op.descriptor.model);
this.emissionEmitter.fire({ type: 'record', record });
const event = op.descriptor.toEvent?.(op.payload, inst.state);
if (event !== undefined && this.eventBus !== undefined) {
@ -289,20 +281,27 @@ export class WireService extends Disposable implements IWireService {
return { type: op.type, payload };
}
private appendToWireLog(record: PersistedRecord): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private appendToWireLog(record: PersistedRecord, model: ModelDef<any>): void {
if (this.log === undefined) return;
// When the blob hook is active, every append rides the serialized queue so a
// record with no offloadable targets cannot leapfrog a pending offload and
// reorder the log; otherwise append directly (no microtask, no queue).
if (this.blobService === undefined || this.options.blobSelector === undefined) {
// When the model declares a blob codec and the blob service is available,
// every append rides the serialized queue so a record with no offloadable
// targets cannot leapfrog a pending dehydrate and reorder the log; otherwise
// append directly (no microtask, no queue).
if (this.blobService === undefined || model.blobs?.dehydrate === undefined) {
this.log.append(this.options.logScope, this.options.logKey, record, {
onError: onUnexpectedError,
});
return;
}
const dehydrate = model.blobs.dehydrate;
const transform: PartsTransformer = (parts) =>
this.blobService!.offloadParts(
parts as readonly ContentPart[],
) as Promise<readonly unknown[]>;
this.persistQueue = this.persistQueue
.then(async () => {
const prepared = this.prepareRecord(record);
const prepared = dehydrate(record, transform);
const offloaded = isPromise(prepared) ? await prepared : prepared;
this.log?.append(this.options.logScope, this.options.logKey, offloaded, {
onError: onUnexpectedError,
@ -311,44 +310,20 @@ export class WireService extends Disposable implements IWireService {
.catch((error: unknown) => onUnexpectedError(error));
}
private prepareRecord(record: PersistedRecord): PersistedRecord | Promise<PersistedRecord> {
const blobService = this.blobService;
const selector = this.options.blobSelector;
if (blobService === undefined || selector === undefined) return record;
const targets = [...selector(record)];
if (targets.length === 0) return record;
return this.offloadTargets(record, targets, blobService);
}
private async offloadTargets(
record: PersistedRecord,
targets: readonly WireBlobTarget[],
blobService: IAgentBlobService,
): Promise<PersistedRecord> {
let current = record;
for (const target of targets) {
const parts = await blobService.offloadParts(target.parts);
if (parts !== target.parts) {
current = target.replace(current, parts);
}
}
return current;
}
private async rehydrateModels(): Promise<void> {
if (this.blobService === undefined) return;
const rehydrateParts: PartsRehydrator = (parts) =>
this.blobService!.rehydrateParts(
const transform: PartsTransformer = (parts) =>
this.blobService!.loadParts(
parts as readonly ContentPart[],
) as Promise<readonly unknown[]>;
for (const [def, inst] of this.models) {
if (def.rehydrate === undefined) continue;
const result = def.rehydrate(inst.state, rehydrateParts);
if (def.blobs?.rehydrate === undefined) continue;
const result = def.blobs.rehydrate(inst.state, transform);
inst.state = Object.freeze(isPromise(result) ? await result : result);
}
for (const [def, inst] of this.derivedModels) {
if (def.rehydrate === undefined) continue;
const result = def.rehydrate(inst.state, rehydrateParts);
if (def.blobs?.rehydrate === undefined) continue;
const result = def.blobs.rehydrate(inst.state, transform);
inst.state = Object.freeze(isPromise(result) ? await result : result);
}
}

View file

@ -102,7 +102,7 @@ function stubBlobPassThrough(ix: TestInstantiationService): void {
ix.stub(IAgentBlobService, {
_serviceBrand: undefined,
offloadParts: async (parts) => parts,
rehydrateParts: async (parts) => parts,
loadParts: async (parts) => parts,
isBlobRef: () => false,
} satisfies IAgentBlobService);
}

View file

@ -88,7 +88,7 @@ describe('AgentBlobServiceImpl', () => {
const parts: ContentPart[] = [{ type: 'image_url', imageUrl: { url: uri } }];
const offloaded = await store.offloadParts(parts);
const rehydrated = await store.rehydrateParts(offloaded);
const rehydrated = await store.loadParts(offloaded);
expect((rehydrated[0]! as { imageUrl: { url: string } }).imageUrl.url).toBe(uri);
});
@ -106,7 +106,7 @@ describe('AgentBlobServiceImpl', () => {
expect(store.isBlobRef((offloaded[0]! as { imageUrl: { url: string } }).imageUrl.url)).toBe(true);
expect((offloaded[1]! as { audioUrl: { url: string } }).audioUrl.url).toBe(smallUri);
const rehydrated = await store.rehydrateParts(offloaded);
const rehydrated = await store.loadParts(offloaded);
expect((rehydrated[0]! as { imageUrl: { url: string } }).imageUrl.url).toBe(largeUri);
expect((rehydrated[1]! as { audioUrl: { url: string } }).audioUrl.url).toBe(smallUri);
});
@ -117,7 +117,7 @@ describe('AgentBlobServiceImpl', () => {
{ type: 'image_url', imageUrl: { url: 'blobref:image/png;deadbeef' } },
];
const result = await store.rehydrateParts(parts);
const result = await store.loadParts(parts);
expect((result[0]! as { imageUrl: { url: string } }).imageUrl.url).toBe('[media missing]');
});
@ -165,7 +165,7 @@ describe('AgentBlobServiceImpl', () => {
).toBe(payload);
expect(await backend.list('blobs')).toHaveLength(0);
const rehydrated = await store.rehydrateParts(offloaded);
const rehydrated = await store.loadParts(offloaded);
expect((rehydrated[0]! as { imageUrl: { url: string } }).imageUrl.url).toBe(uri);
});
});

View file

@ -190,7 +190,7 @@ describe('blobref', () => {
const dataUri = `data:image/jpeg;base64,${payload}`;
const offloaded = await store.offloadParts([imagePart(dataUri)]);
const rehydrated = await store.rehydrateParts(offloaded);
const rehydrated = await store.loadParts(offloaded);
expect(firstImageUrl(rehydrated)).toBe(dataUri);
expect(firstImageUrl(offloaded)).toMatch(/^blobref:image\/jpeg;/);
@ -198,7 +198,7 @@ describe('blobref', () => {
it('replaces missing blobs with placeholder text', async () => {
const { store } = await makeStore();
const rehydrated = await store.rehydrateParts([imagePart('blobref:image/png;deadbeef')]);
const rehydrated = await store.loadParts([imagePart('blobref:image/png;deadbeef')]);
expect(firstImageUrl(rehydrated)).toBe(MISSING_MEDIA_PLACEHOLDER);
});
@ -225,7 +225,7 @@ describe('blobref', () => {
expect(files).toHaveLength(1);
await rm(join(blobsDir, files[0]!));
const rehydrated = await store.rehydrateParts(offloaded);
const rehydrated = await store.loadParts(offloaded);
expect(firstImageUrl(rehydrated)).toBe(dataUri);
});
@ -239,14 +239,14 @@ describe('blobref', () => {
const offloaded = await writer.offloadParts([imagePart(dataUri)]);
const blobref = firstImageUrl(offloaded);
const firstRead = await reader.rehydrateParts([imagePart(blobref)]);
const firstRead = await reader.loadParts([imagePart(blobref)]);
expect(firstImageUrl(firstRead)).toBe(dataUri);
const files = await readdir(blobsDir);
expect(files).toHaveLength(1);
await rm(join(blobsDir, files[0]!));
const secondRead = await reader.rehydrateParts([imagePart(blobref)]);
const secondRead = await reader.loadParts([imagePart(blobref)]);
expect(firstImageUrl(secondRead)).toBe(dataUri);
});
@ -262,7 +262,7 @@ describe('blobref', () => {
const blobrefA = firstImageUrl(offloadedA);
const blobrefB = firstImageUrl(offloadedB);
await store.rehydrateParts([imagePart(blobrefA)]);
await store.loadParts([imagePart(blobrefA)]);
const offloadedC = await store.offloadParts([imagePart(`data:image/png;base64,${payloadC}`)]);
const blobrefC = firstImageUrl(offloadedC);
@ -271,13 +271,13 @@ describe('blobref', () => {
await rm(join(blobsDir, file));
}
expect(firstImageUrl(await store.rehydrateParts([imagePart(blobrefA)]))).toBe(
expect(firstImageUrl(await store.loadParts([imagePart(blobrefA)]))).toBe(
`data:image/png;base64,${payloadA}`,
);
expect(firstImageUrl(await store.rehydrateParts([imagePart(blobrefB)]))).toBe(
expect(firstImageUrl(await store.loadParts([imagePart(blobrefB)]))).toBe(
MISSING_MEDIA_PLACEHOLDER,
);
expect(firstImageUrl(await store.rehydrateParts([imagePart(blobrefC)]))).toBe(
expect(firstImageUrl(await store.loadParts([imagePart(blobrefC)]))).toBe(
`data:image/png;base64,${payloadC}`,
);
});
@ -298,10 +298,10 @@ describe('blobref', () => {
await rm(join(blobsDir, file));
}
expect(firstImageUrl(await store.rehydrateParts([imagePart(smallBlobref)]))).toBe(
expect(firstImageUrl(await store.loadParts([imagePart(smallBlobref)]))).toBe(
`data:image/png;base64,${small}`,
);
expect(firstImageUrl(await store.rehydrateParts([imagePart(largeBlobref)]))).toBe(
expect(firstImageUrl(await store.loadParts([imagePart(largeBlobref)]))).toBe(
MISSING_MEDIA_PLACEHOLDER,
);
});

View file

@ -1,11 +1,11 @@
/**
* `AgentContextMemoryService` wire contract, exercised without the full agent
* harness (mirror of `test/goal/goal-wire.test.ts`): a `TestInstantiationService`
* + `InMemoryStorageService` + `AppendLogStore` + `WireService` seeded with the
* `contextBlobSelector` and a stub `IAgentBlobService`. Covers the splice Ops'
* NEW-reference + flat-record shape, the live-only `onSpliced` hook (silent on
* replay), and load-bearing the blob offload-on-dispatch
* rehydrate-on-replay round-trip.
* + `InMemoryStorageService` + `AppendLogStore` + `WireService` + stub
* `IAgentBlobService`. Covers the splice Ops' NEW-reference + flat-record shape,
* the live-only `onSpliced` hook (silent on replay), and load-bearing the
* blob dehydrate-on-dispatch rehydrate-on-replay round-trip via
* `ContextModel.blobs`.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
@ -16,7 +16,6 @@ import { TestInstantiationService } from '#/_base/di/test';
import { IAgentBlobService } from '#/agent/blob';
import {
AgentContextMemoryService,
contextBlobSelector,
ContextModel,
contextAppendMessage,
contextApplyCompaction,
@ -54,7 +53,7 @@ class StubBlobService implements IAgentBlobService {
declare readonly _serviceBrand: undefined;
readonly store = new Map<string, string>();
offloadCalls = 0;
rehydrateCalls = 0;
loadCalls = 0;
private seq = 0;
isBlobRef(url: string): boolean {
@ -71,7 +70,7 @@ class StubBlobService implements IAgentBlobService {
return changed ? out : parts;
}
async rehydrateParts(parts: readonly ContentPart[]): Promise<readonly ContentPart[]> {
async loadParts(parts: readonly ContentPart[]): Promise<readonly ContentPart[]> {
let changed = false;
const out = parts.map((part) => {
const next = this.rehydratePart(part);
@ -109,7 +108,7 @@ class StubBlobService implements IAgentBlobService {
const sha = rest.slice(semi + 1);
const payload = this.store.get(sha);
if (payload === undefined) continue;
this.rehydrateCalls++;
this.loadCalls++;
return { ...obj, [key]: { ...media, url: `data:${mime};base64,${payload}` } } as unknown as ContentPart;
}
return part;
@ -158,7 +157,7 @@ function buildHost(key: string): Host {
ix.set(
IAgentWireService,
new SyncDescriptor(WireService, [
{ logScope: SCOPE, logKey: key, blobSelector: contextBlobSelector },
{ logScope: SCOPE, logKey: key },
]),
);
ix.stub(IAgentBlobService, blob);
@ -301,7 +300,7 @@ describe('AgentContextMemoryService (wire-backed)', () => {
const replay = buildHost(REPLAY_KEY);
await replay.wire.replay(...records);
expect(blob.rehydrateCalls).toBeGreaterThanOrEqual(1);
expect(blob.loadCalls).toBeGreaterThanOrEqual(1);
const rebuilt = replay.wire.getModel(ContextModel) as readonly ContextMessage[];
expect(rebuilt).toEqual(live);