tui-e2e-record.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import React from "react";
  2. import { render } from "ink-testing-library";
  3. import { writeFileSync } from "node:fs";
  4. import { resolve } from "node:path";
  5. import { TuiApp } from "../src/adapters/channel/tui/app/TuiApp.js";
  6. import { createGateway } from "../src/gateway/index.js";
  7. import { createModelRuntime } from "../src/model/index.js";
  8. import { createDefaultPermissionContext, PermissionRuntime } from "../src/permission/index.js";
  9. import { loadPilotConfig } from "../src/pilot/index.js";
  10. import { createRouterRuntime } from "../src/router/index.js";
  11. import {
  12. SequentialToolScheduler,
  13. ToolRegistry,
  14. ToolRuntime,
  15. type PilotDeckToolDefinition,
  16. } from "../src/tool/index.js";
  17. import type { AgentRuntimeConfig } from "../src/agent/index.js";
  18. import { createAgentSession } from "../src/agent/index.js";
  19. const PROVIDER = process.env.PILOTDECK_E2E_PROVIDER ?? "edgeclaw";
  20. const MODEL = process.env.PILOTDECK_E2E_MODEL ?? "moonshotai/kimi-k2.6";
  21. const PROMPT = process.env.PILOTDECK_E2E_PROMPT ?? "Use add_numbers to compute 17 + 25, then tell me the result.";
  22. const addNumbersTool: PilotDeckToolDefinition = {
  23. name: "add_numbers",
  24. description: "Add two numbers and return the result.",
  25. kind: "custom",
  26. inputSchema: {
  27. type: "object",
  28. required: ["a", "b"],
  29. additionalProperties: false,
  30. properties: {
  31. a: { type: "number" },
  32. b: { type: "number" },
  33. },
  34. },
  35. isReadOnly: () => true,
  36. isConcurrencySafe: () => true,
  37. execute: async (input) => {
  38. const { a, b } = input as { a: number; b: number };
  39. return { content: [{ type: "text", text: String(a + b) }], data: { sum: a + b } };
  40. },
  41. };
  42. async function main(): Promise<void> {
  43. const snapshot = loadPilotConfig();
  44. const provider = snapshot.config.model.providers[PROVIDER];
  45. if (!provider?.models[MODEL]) {
  46. throw new Error(`Provider ${PROVIDER} or model ${MODEL} is not configured.`);
  47. }
  48. const cwd = process.cwd();
  49. const registry = new ToolRegistry();
  50. registry.register(addNumbersTool);
  51. const permissionRuntime = new PermissionRuntime();
  52. const toolRuntime = new ToolRuntime(registry, permissionRuntime);
  53. const scheduler = new SequentialToolScheduler(toolRuntime);
  54. const modelRuntime = createModelRuntime(snapshot.config.model);
  55. const config: AgentRuntimeConfig = {
  56. provider: PROVIDER,
  57. model: MODEL,
  58. cwd,
  59. systemPrompt:
  60. "You are PilotDeck running an end-to-end TUI test. When asked for arithmetic, you MUST call the provided add_numbers tool exactly once instead of computing it yourself, then report the answer in plain text.",
  61. maxOutputTokens: 1024,
  62. temperature: 0,
  63. permissionMode: "default",
  64. permissionContext: createDefaultPermissionContext({
  65. cwd,
  66. mode: "default",
  67. canPrompt: false,
  68. bypassAvailable: true,
  69. }),
  70. metadata: { test: "tui-e2e-record" },
  71. };
  72. const router = createRouterRuntime(
  73. snapshot.config.router ?? {
  74. scenarios: {
  75. default: { id: `${PROVIDER}/${MODEL}`, provider: PROVIDER, model: MODEL },
  76. },
  77. zeroUsageRetry: { enabled: true, maxAttempts: 5 },
  78. },
  79. { modelRuntime },
  80. );
  81. const baseGateway = createGateway({
  82. session: {
  83. create: async ({ sessionKey }) =>
  84. createAgentSession({
  85. sessionId: sessionKey,
  86. config,
  87. dependencies: {
  88. router,
  89. tools: { registry, scheduler },
  90. },
  91. }),
  92. },
  93. serverInfo: { mode: "in_process", projectKey: cwd },
  94. });
  95. const gateway = process.env.PILOTDECK_E2E_TRACE === "1" ? wrapWithTrace(baseGateway) : baseGateway;
  96. const tree = (
  97. <TuiApp
  98. gateway={gateway}
  99. connection="in_process"
  100. projectKey={cwd}
  101. cwd={cwd}
  102. model={`${PROVIDER} · ${MODEL}`}
  103. />
  104. );
  105. const instance = render(tree);
  106. const writeFrame = (label: string) => {
  107. process.stdout.write(`\n--- ${label} (frame #${instance.frames.length}) ---\n`);
  108. process.stdout.write(`${instance.lastFrame() ?? ""}\n`);
  109. };
  110. await wait(120);
  111. writeFrame("cold start");
  112. for (const ch of PROMPT) {
  113. instance.stdin.write(ch);
  114. await wait(8);
  115. }
  116. await wait(120);
  117. writeFrame("after typing prompt");
  118. instance.stdin.write("\r");
  119. writeFrame("submit");
  120. const finalFrame = await waitForCompletedFrame(instance, 120_000);
  121. writeFrame("final");
  122. const logPath = resolve(process.cwd(), "artifacts/tui-e2e-frames.log");
  123. writeFileSync(logPath, instance.frames.map((frame, index) => `--- frame ${index} ---\n${frame}\n`).join("\n"));
  124. process.stdout.write(`\nSaved ${instance.frames.length} frames to ${logPath}\n`);
  125. instance.unmount();
  126. if (!finalFrame) {
  127. throw new Error("Timed out waiting for the assistant final frame.");
  128. }
  129. }
  130. function wrapWithTrace(gateway: ReturnType<typeof createGateway>): ReturnType<typeof createGateway> {
  131. return new Proxy(gateway, {
  132. get(target, prop, receiver) {
  133. const original = Reflect.get(target, prop, receiver);
  134. if (prop !== "submitTurn" || typeof original !== "function") {
  135. return original;
  136. }
  137. return (...args: Parameters<typeof gateway.submitTurn>) => {
  138. const startedAt = Date.now();
  139. let lastAt = startedAt;
  140. let textChars = 0;
  141. const iterable = original.apply(target, args) as AsyncIterable<unknown>;
  142. const ms = (now: number) => `${(now - startedAt).toString().padStart(5)} ms`;
  143. process.stdout.write(`\n[trace] submitTurn() called\n`);
  144. return (async function* () {
  145. for await (const event of iterable) {
  146. const now = Date.now();
  147. const delta = now - lastAt;
  148. lastAt = now;
  149. const ev = event as { type: string; text?: string; name?: string; toolCallId?: string };
  150. const type = ev.type;
  151. let detail = "";
  152. if (type === "assistant_text_delta") {
  153. textChars += ev.text?.length ?? 0;
  154. detail = ` text+=${ev.text?.length ?? 0} total=${textChars}`;
  155. } else if (type === "tool_call_started" || type === "tool_call_finished") {
  156. detail = ` ${ev.name ?? ev.toolCallId ?? ""}`;
  157. } else if (type === "error") {
  158. detail = ` "${(event as { message?: string }).message ?? ""}"`;
  159. }
  160. process.stdout.write(`[trace ${ms(now)} +${delta.toString().padStart(4)}ms] ${type}${detail}\n`);
  161. yield event;
  162. }
  163. const total = Date.now() - startedAt;
  164. process.stdout.write(`[trace] turn finished after ${total} ms (${textChars} assistant chars)\n`);
  165. })();
  166. };
  167. },
  168. });
  169. }
  170. function wait(ms: number): Promise<void> {
  171. return new Promise((resolveTimer) => setTimeout(resolveTimer, ms));
  172. }
  173. async function waitForFrame(
  174. instance: ReturnType<typeof render>,
  175. pattern: RegExp,
  176. timeoutMs: number,
  177. ): Promise<string | undefined> {
  178. const deadline = Date.now() + timeoutMs;
  179. while (Date.now() < deadline) {
  180. const frame = instance.lastFrame();
  181. if (frame && pattern.test(frame)) {
  182. return frame;
  183. }
  184. await wait(120);
  185. }
  186. return undefined;
  187. }
  188. async function waitForCompletedFrame(
  189. instance: ReturnType<typeof render>,
  190. timeoutMs: number,
  191. ): Promise<string | undefined> {
  192. const deadline = Date.now() + timeoutMs;
  193. while (Date.now() < deadline) {
  194. const frame = instance.lastFrame() ?? "";
  195. const hasResult = /\b42\b/.test(frame);
  196. const stillThinking = /✦ thinking/.test(frame);
  197. if (hasResult && !stillThinking) {
  198. return frame;
  199. }
  200. await wait(150);
  201. }
  202. return undefined;
  203. }
  204. async function waitForStableFrame(
  205. instance: ReturnType<typeof render>,
  206. timeoutMs: number,
  207. stableMs = 600,
  208. ): Promise<string | undefined> {
  209. const deadline = Date.now() + timeoutMs;
  210. let last = instance.lastFrame();
  211. let lastChange = Date.now();
  212. while (Date.now() < deadline) {
  213. const current = instance.lastFrame();
  214. if (current !== last) {
  215. last = current;
  216. lastChange = Date.now();
  217. } else if (Date.now() - lastChange >= stableMs) {
  218. return current;
  219. }
  220. await wait(120);
  221. }
  222. return last;
  223. }
  224. main().catch((error) => {
  225. console.error(error);
  226. process.exitCode = 1;
  227. });