File size: 8,040 Bytes
f0634fb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 | /**
* Scripted LLM provider seam for "real" ACP turn tests.
*
* Boots the full engine + ACP wire but replaces the wire `ChatProvider` with a
* deterministic one that replays a FIFO queue of scripted responses. This keeps
* the entire real stack — JSON-RPC, `AcpSession`, the agent turn loop,
* `ModelImpl.request`, the real `generate()` stream-merge, `IEventBus`
* `assistant.delta` → ACP `session/update`, tool execution, and the
* approval / question bridge — and fakes only the network LLM call.
*
* Usage:
* const { seed, mockNextResponse } = createScriptedProvider();
* mockNextResponse({ type: 'text', text: 'hi' });
* const client = await createTestClient({ homeDir, extraSeeds: [seed] });
*
* The seed shadows the App-scope `IProtocolAdapterRegistry`, so every Model the
* resolver builds routes its `createChatProvider()` call into the scripted
* provider regardless of protocol.
*/
import {
IProtocolAdapterRegistry,
type IProtocolAdapterRegistry as IProtocolAdapterRegistryType,
type Message,
type Model,
ProtocolAdapterRegistry,
type ProtocolAdapterConfig,
type StreamedMessagePart,
type TokenUsage,
type Tool,
} from '@moonshot-ai/agent-core-v2';
import type { FinishReason } from '@moonshot-ai/agent-core-v2/human/llm/finish-reason';
import { fromLlmMessage } from '@moonshot-ai/agent-core-v2/llm-adapter/contract/message';
import type { LlmRequester } from '@moonshot-ai/agent-core-v2/human/llm/requester/requester';
interface ScriptedResponse {
readonly parts: readonly StreamedMessagePart[];
readonly finishReason?: FinishReason | null;
readonly rawFinishReason?: string | null;
}
const ZERO_USAGE: TokenUsage = {
inputOther: 0,
output: 0,
inputCacheRead: 0,
inputCacheCreation: 0,
};
/**
* Async-iterable `StreamedMessage` backed by a fixed part list. Terminal fields
* (`id` / `usage` / `finishReason` / `rawFinishReason`) are populated when the
* iterator completes — matching the real `generate()` driver, which reads them
* after its `for await` loop drains the stream.
*/
class ScriptedStream {
id: string | null = null;
usage: TokenUsage | null = null;
finishReason: FinishReason | null = null;
rawFinishReason: string | null = null;
constructor(
private readonly parts: readonly StreamedMessagePart[],
private readonly response: ScriptedResponse,
private readonly index: number,
) {}
async *[Symbol.asyncIterator](): AsyncIterator<StreamedMessagePart> {
for (const part of this.parts) {
yield part;
}
const hasToolCall = this.parts.some((p) => p.type === 'function');
this.id = `scripted-${String(this.index)}`;
this.usage = { ...ZERO_USAGE, output: this.parts.length };
this.finishReason =
this.response.finishReason ?? (hasToolCall ? 'tool_calls' : 'completed');
this.rawFinishReason =
this.response.rawFinishReason ?? (this.finishReason === 'completed' ? 'stop' : this.finishReason);
}
}
class ScriptedChatProvider {
readonly name = 'scripted';
readonly modelName = 'scripted';
readonly thinkingEffort = null;
constructor(
private readonly queue: ScriptedResponse[],
private readonly calls: Array<readonly Message[]>,
) {}
async generate(
_systemPrompt: string,
_tools: readonly Tool[],
history: readonly Message[],
options?: { signal?: AbortSignal },
): Promise<ScriptedStream> {
options?.signal?.throwIfAborted();
const response = this.queue.shift();
if (response === undefined) {
throw new Error(
`scriptedProvider: unexpected generate() call #${String(this.calls.length + 1)} — ` +
`queue exhausted. Push another response via mockNextResponse().`,
);
}
this.calls.push(history);
return new ScriptedStream(response.parts, response, this.calls.length);
}
withThinking(): ScriptedChatProvider {
return this;
}
withMaxCompletionTokens(): ScriptedChatProvider {
return this;
}
}
export interface ScriptedProvider {
/** App-scope seed tuple to pass as `extraSeeds: [seed]`. */
readonly seed: readonly [typeof IProtocolAdapterRegistry, IProtocolAdapterRegistryType];
/** Push a text-only assistant response onto the queue. */
mockNextText(text: string): void;
/** Push a response assembled from arbitrary streamed parts. */
mockNextResponse(...parts: StreamedMessagePart[]): void;
/** Push a response with an explicit finish reason. */
mockNextProviderResponse(response: {
readonly parts?: readonly StreamedMessagePart[];
readonly finishReason?: FinishReason | null;
readonly rawFinishReason?: string | null;
}): void;
/** Number of `generate()` calls the engine has made so far. */
callCount(): number;
/** The `history` argument of every `generate()` call so far, in order. */
callHistory(): ReadonlyArray<readonly Message[]>;
}
export function createScriptedProvider(): ScriptedProvider {
const queue: ScriptedResponse[] = [];
const calls: Array<readonly Message[]> = [];
// Single shared provider so every ModelImpl in the process (main agent,
// sub-agents) draws from the same FIFO queue.
const provider = new ScriptedChatProvider(queue, calls);
const requester: LlmRequester = {
async generate(config, content, control) {
control.onEvent?.({ type: 'llm.sent' });
try {
const stream = await provider.generate(
config.systemPrompt ?? '',
[...(config.tools ?? [])],
content.messages.map(fromLlmMessage),
{ signal: control.signal },
);
for await (const part of stream) {
control.onEvent?.({ type: 'llm.streaming.part', part });
control.signal.throwIfAborted();
}
control.onEvent?.({ type: 'llm.streaming.usage', usage: stream.usage ?? ZERO_USAGE });
control.onEvent?.({
type: 'llm.streaming.finish',
finish: {
finishReason: stream.finishReason,
rawFinishReason: stream.rawFinishReason,
},
});
if (stream.id !== null) {
control.onEvent?.({ type: 'llm.streaming.message_id', messageId: stream.id });
}
control.onEvent?.({ type: 'llm.done' });
} catch (error) {
control.onEvent?.({
type: 'llm.failed.remote',
error: {
kind: 'unknown',
message: error instanceof Error ? error.message : String(error),
},
});
}
},
};
// Identity/capability/model resolution delegates to the real registry (the
// interface grew `resolveAdapterIdentity` / `resolveProviderBaseId` /
// `resolveCapability` / `resolve` — delegating keeps the
// stub truthful and immune to further growth); only the requester is scripted.
const real = new ProtocolAdapterRegistry();
const registry: IProtocolAdapterRegistryType = {
_serviceBrand: undefined,
supportedProtocols: () => real.supportedProtocols(),
resolveAdapterIdentity: real.resolveAdapterIdentity.bind(real),
resolveProviderBaseId: real.resolveProviderBaseId.bind(real),
resolveCapability: real.resolveCapability.bind(real),
resolve: (model: Model) => ({ ...real.resolve(model), requester }),
// `createChatProvider` is called by `ModelImpl` (a package-internal method
// not on the public interface); present at runtime, cast for the type gap.
createChatProvider: (_input: ProtocolAdapterConfig) => provider,
} as unknown as IProtocolAdapterRegistryType;
return {
seed: [IProtocolAdapterRegistry, registry],
mockNextText: (text) => {
queue.push({ parts: [{ type: 'text', text }] });
},
mockNextResponse: (...parts) => {
queue.push({ parts: structuredClone(parts) });
},
mockNextProviderResponse: (response) => {
queue.push({
parts: structuredClone(response.parts ?? []),
finishReason: response.finishReason,
rawFinishReason: response.rawFinishReason,
});
},
callCount: () => calls.length,
callHistory: () => calls,
};
}
|