Skip to content
81 changes: 81 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3180,6 +3180,87 @@ describe("AcpAdapterV2", () => {
}).pipe(Effect.provide(layerTest)),
);

it.effect("replays history in order, with stable earlier snapshots", () =>
Comment thread
only21mil marked this conversation as resolved.
Resolved
Effect.gen(function* () {
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const path = yield* Path.Path;
const selfInvocation = yield* resolveSelfInvocation();
const mockAgentPath = yield* path.fromFileUrl(
new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
);
type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
const instanceId = ProviderInstanceId.make("acp-test-v2-replay-order");
const adapter = yield* makeAcpAdapterV2({
instanceId,
flavor: {
driver: ACP_TEST_DRIVER,
capabilities: AcpProviderCapabilitiesV2,
makeRuntime: makeMockRuntime({
childProcessSpawner,
mockAgentPath,
wrapRuntime: (runtime) => ({
...runtime,
handleSessionUpdate: (handler) =>
Effect.sync(() => {
sessionUpdateHandler = handler;
}).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
}),
}),
},
selfInvocation,
});
const threadId = ThreadId.make("thread-acp-v2-replay-order");
const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "full-access",
interactionMode: "default",
cwd: process.cwd(),
});
const modelSelection = { instanceId, model: "default" } as const;
const runtime = yield* adapter.openSession({
threadId,
providerSessionId: ProviderSessionId.make("provider-session-acp-v2-replay-order"),
modelSelection,
runtimePolicy,
});
const providerThread = yield* runtime.ensureThread({
threadId,
modelSelection,
runtimePolicy,
});
assert.isDefined(sessionUpdateHandler);
const replay = (
sessionUpdate: "user_message_chunk" | "agent_message_chunk",
messageId: string,
text: string,
) =>
sessionUpdateHandler!({
sessionId: "mock-session-1",
update: { sessionUpdate, messageId, content: { type: "text", text } },
});

yield* replay("user_message_chunk", "u1", "hel");
yield* replay("user_message_chunk", "u1", "lo");
yield* replay("agent_message_chunk", "a1", "hi");
yield* replay("user_message_chunk", "u2", "next");
yield* replay("agent_message_chunk", "a1", " there");
const first = yield* runtime.readThreadSnapshot({ providerThread });
yield* replay("agent_message_chunk", "a2", "done");
yield* replay("user_message_chunk", "u2", " one");
const second = yield* runtime.readThreadSnapshot({ providerThread });

const texts = (snapshot: typeof first) =>
snapshot.messages.map((message) => `${message.role}:${message.text}`);
assert.deepEqual(texts(first), ["user:hello", "assistant:hi there", "user:next"]);
assert.deepEqual(texts(second), [
"user:hello",
"assistant:hi there",
"user:next one",
"assistant:done",
]);
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

it.effect("persists a bounded subset of streamed root and child tool updates", () =>
Effect.gen(function* () {
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
Expand Down
28 changes: 12 additions & 16 deletions packages/provider-acp/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1729,6 +1729,7 @@ type PendingRuntimeRequest = {
}
);

/** Writers update `order` and `messages` in place; readers copy them out. */
interface SnapshotMessageState {
readonly order: Array<string>;
readonly messages: Map<string, OrchestrationV2ConversationMessage>;
Expand Down Expand Up @@ -2781,14 +2782,9 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
const rememberSnapshotMessage = (message: OrchestrationV2ConversationMessage) =>
Ref.update(snapshot, (current) => {
const key = String(message.id);
const exists = current.messages.has(key);
const messages = new Map(current.messages);
messages.set(key, message);
return {
...current,
order: exists ? current.order : [...current.order, key],
messages,
};
if (!current.messages.has(key)) current.order.push(key);
current.messages.set(key, message);
return current;
});

const textStreamFor = (
Expand Down Expand Up @@ -4664,8 +4660,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
const messageId = providerMessageId(nativeItemId);
const key = String(messageId);
const previous = current.messages.get(key);
const messages = new Map(current.messages);
messages.set(key, {
if (previous === undefined) current.order.push(key);
current.messages.set(key, {
createdBy: previous?.createdBy ?? (role === "user" ? "user" : "agent"),
creationSource: previous?.creationSource ?? "provider",
id: messageId,
Expand All @@ -4680,8 +4676,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
updatedAt: now,
});
return {
order: current.order.includes(key) ? current.order : [...current.order, key],
messages,
...current,
reasoningIds:
role === "thought" && !current.reasoningIds.has(messageId)
? new Set(current.reasoningIds).add(messageId)
Expand Down Expand Up @@ -9517,6 +9512,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
yield* Ref.set(activeInteractionMode, null);
}
const state = yield* Ref.get(snapshot);
const messages = state.order.flatMap((key) => {
const message = state.messages.get(key);
return message === undefined ? [] : [message];
});
const now = yield* DateTime.now;
return {
providerThread: {
Expand All @@ -9526,10 +9525,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
updatedAt: now,
},
providerTurns: [...(yield* Ref.get(providerTurns)).values()],
messages: state.order.flatMap((key) => {
const message = state.messages.get(key);
return message === undefined ? [] : [message];
}),
messages,
reasoningMessageIds: [...state.reasoningIds],
runtimeRequests: [],
providerPayload: { protocol: ACP_PROTOCOL, sessionId },
Expand Down
Loading