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,
  };
}