@loadmill/droid-cua 2.9.0 → 2.10.1-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +0 -2
  2. package/build/index.js +24 -18
  3. package/build/src/brand/loadmill-mark.js +20 -0
  4. package/build/src/browser/playwright/connection.js +148 -11
  5. package/build/src/browser/playwright/welcome-page.js +161 -0
  6. package/build/src/cli/headless-execution-config.js +16 -24
  7. package/build/src/cli/headless-target.js +0 -9
  8. package/build/src/commands/help.js +3 -5
  9. package/build/src/commands/run.js +1 -1
  10. package/build/src/core/aim-assist.js +5 -0
  11. package/build/src/core/execution-engine.js +266 -505
  12. package/build/src/core/prompts.js +1 -1
  13. package/build/src/core/runtime-config.js +1 -2
  14. package/build/src/core/screenshot-store.js +153 -0
  15. package/build/src/core/tool-handlers/assert.js +155 -0
  16. package/build/src/core/tool-handlers/loadmill.js +29 -0
  17. package/build/src/core/tool-handlers/navigate.js +110 -0
  18. package/build/src/core/tool-handlers/preview-click.js +112 -0
  19. package/build/src/core/tool-handlers/test-design-complete.js +93 -0
  20. package/build/src/core/tool-handlers/utils.js +80 -0
  21. package/build/src/device/openai.js +103 -4
  22. package/build/src/modes/design-mode-ink.js +17 -5
  23. package/build/src/modes/design-mode.js +17 -5
  24. package/build/src/modes/execution-mode.js +42 -234
  25. package/build/src/prompts/base.js +44 -31
  26. package/build/src/prompts/design.js +55 -15
  27. package/build/src/prompts/editor.js +1 -1
  28. package/build/src/prompts/execution.js +15 -10
  29. package/build/src/utils/cua-debug-tracer.js +3 -1
  30. package/package.json +3 -4
  31. package/build/src/device/screenshot-resolution.js +0 -33
@@ -1,3 +1,3 @@
1
1
  export { buildBaseSystemPrompt } from "../prompts/base.js";
2
- export { buildDesignModePrompt, buildDesignRecoveryPrompt } from "../prompts/design.js";
2
+ export { buildDesignModePrompt, buildDesignRecoveryPrompt, buildDesignScriptGenerationPrompt, } from "../prompts/design.js";
3
3
  export { buildAppContextSection, buildAssertionSystemPrompt, buildExecutionModePrompt, buildExecutionNavigationRecoveryPrompt, buildExecutionRecoveryPrompt, } from "../prompts/execution.js";
@@ -35,10 +35,9 @@ export function resolveRuntimeCredentials({ openAiApiKey = null, loadmillApiToke
35
35
  loadmillBaseUrl: trimNullableString(loadmillBaseUrl),
36
36
  };
37
37
  }
38
- export function resolveRuntimeConfig({ cuaModel = null, strictMode = false, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
38
+ export function resolveRuntimeConfig({ cuaModel = null, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
39
39
  return {
40
40
  cuaModel: resolveCuaModel(cuaModel),
41
- strictMode: strictMode === true,
42
41
  stepDelayMs: normalizeStepDelayMs(Number(stepDelayMs)),
43
42
  contextOptimizationEnabled: normalizeContextOptimizationEnabled(contextOptimizationEnabled),
44
43
  contextOptimizationThreshold: normalizeContextOptimizationThreshold(contextOptimizationThreshold),
@@ -0,0 +1,153 @@
1
+ import path from "path";
2
+ import { writeFile } from "fs/promises";
3
+ import { emitDesktopDebug } from "../utils/desktop-debug.js";
4
+ import { annotateScreenshotArtifact } from "../utils/run-screenshot-recorder.js";
5
+ function normalizeStringList(value) {
6
+ if (!Array.isArray(value)) {
7
+ return typeof value === "string" && value.trim() ? [value] : [];
8
+ }
9
+ return value.flatMap((item) => {
10
+ if (typeof item === "string" && item.trim()) {
11
+ return [item];
12
+ }
13
+ if (item && typeof item === "object") {
14
+ return normalizeStringList(item.artifactPaths);
15
+ }
16
+ return [];
17
+ });
18
+ }
19
+ function normalizeRecorderResult(result, screenshotBase64) {
20
+ if (!result) {
21
+ return null;
22
+ }
23
+ if (typeof result === "string") {
24
+ return {
25
+ base64: screenshotBase64,
26
+ artifactPaths: [result],
27
+ annotationSourcePaths: [result],
28
+ screenshotPath: result,
29
+ screenshotDataUrl: null,
30
+ };
31
+ }
32
+ if (Array.isArray(result)) {
33
+ const normalizedItems = result
34
+ .map((item) => normalizeRecorderResult(item, screenshotBase64))
35
+ .filter(Boolean);
36
+ const artifactPaths = normalizedItems.flatMap((item) => item.artifactPaths);
37
+ const annotationSourcePaths = normalizedItems.flatMap((item) => item.annotationSourcePaths);
38
+ return {
39
+ base64: screenshotBase64,
40
+ artifactPaths,
41
+ annotationSourcePaths,
42
+ screenshotPath: normalizedItems.find((item) => item.screenshotPath)?.screenshotPath ?? artifactPaths[0] ?? null,
43
+ screenshotDataUrl: normalizedItems.find((item) => item.screenshotDataUrl)?.screenshotDataUrl ?? null,
44
+ };
45
+ }
46
+ if (typeof result === "object") {
47
+ const artifactPaths = normalizeStringList(result.artifactPaths);
48
+ const annotationSourcePaths = Array.isArray(result.annotationSourcePaths)
49
+ ? normalizeStringList(result.annotationSourcePaths)
50
+ : artifactPaths;
51
+ const screenshotPath = typeof result.screenshotPath === "string" && result.screenshotPath.trim()
52
+ ? result.screenshotPath
53
+ : artifactPaths[0] ?? null;
54
+ const screenshotDataUrl = typeof result.screenshotDataUrl === "string" && result.screenshotDataUrl.trim()
55
+ ? result.screenshotDataUrl
56
+ : null;
57
+ return {
58
+ base64: screenshotBase64,
59
+ artifactPaths,
60
+ annotationSourcePaths,
61
+ screenshotPath,
62
+ screenshotDataUrl,
63
+ };
64
+ }
65
+ return null;
66
+ }
67
+ function debugScope(metadata = {}) {
68
+ return typeof metadata.sessionId === "string" ? "design" : "execution";
69
+ }
70
+ function debugIds(metadata = {}) {
71
+ if (debugScope(metadata) === "design") {
72
+ return {
73
+ sessionId: metadata.sessionId,
74
+ stepId: metadata.stepId,
75
+ instructionIndex: metadata.instructionIndex,
76
+ };
77
+ }
78
+ return {
79
+ runId: metadata.runId,
80
+ stepId: metadata.stepId,
81
+ instructionIndex: metadata.instructionIndex,
82
+ };
83
+ }
84
+ export class ScreenshotStore {
85
+ constructor({ recordScreenshots = false, screenshotDir = null, screenshotRecorder = null } = {}) {
86
+ this.recordScreenshots = recordScreenshots;
87
+ this.screenshotDir = screenshotDir;
88
+ this.screenshotRecorder = screenshotRecorder;
89
+ this.reportedWriteError = false;
90
+ this.reportedAnnotateError = false;
91
+ }
92
+ async saveScreenshot(screenshotBase64, metadata = {}) {
93
+ if (typeof screenshotBase64 !== "string" || !screenshotBase64) {
94
+ return null;
95
+ }
96
+ try {
97
+ if (this.screenshotRecorder?.saveScreenshot) {
98
+ const result = await this.screenshotRecorder.saveScreenshot(screenshotBase64, metadata);
99
+ return normalizeRecorderResult(result, screenshotBase64);
100
+ }
101
+ if (this.recordScreenshots && this.screenshotDir) {
102
+ const framePath = path.join(this.screenshotDir, `frame_${String(Date.now())}.png`);
103
+ await writeFile(framePath, Buffer.from(screenshotBase64, "base64"));
104
+ return normalizeRecorderResult(framePath, screenshotBase64);
105
+ }
106
+ }
107
+ catch (error) {
108
+ this.reportWriteError(error, metadata);
109
+ }
110
+ return null;
111
+ }
112
+ async annotateScreenshot(savedScreenshot, marker = {}, metadata = {}) {
113
+ const sourcePaths = Array.isArray(savedScreenshot?.annotationSourcePaths)
114
+ ? savedScreenshot.annotationSourcePaths.filter((filePath) => typeof filePath === "string" && filePath.trim())
115
+ : [];
116
+ if (sourcePaths.length === 0) {
117
+ return null;
118
+ }
119
+ try {
120
+ const sourcePath = sourcePaths[0];
121
+ if (sourcePath && this.screenshotRecorder?.saveAnnotatedScreenshot) {
122
+ const result = await this.screenshotRecorder.saveAnnotatedScreenshot(sourcePath, marker, metadata);
123
+ return normalizeRecorderResult(result, savedScreenshot.base64 ?? null);
124
+ }
125
+ await Promise.all(sourcePaths.map((filePath) => annotateScreenshotArtifact(filePath, marker)));
126
+ return null;
127
+ }
128
+ catch (error) {
129
+ this.reportAnnotateError(error, marker, metadata);
130
+ return null;
131
+ }
132
+ }
133
+ reportWriteError(error, metadata = {}) {
134
+ if (this.reportedWriteError)
135
+ return;
136
+ this.reportedWriteError = true;
137
+ emitDesktopDebug("run.screenshot_write.error", debugScope(metadata), debugIds(metadata), {
138
+ captureSource: metadata.captureSource ?? null,
139
+ message: error instanceof Error ? error.message : "Failed to persist screenshot",
140
+ });
141
+ }
142
+ reportAnnotateError(error, marker = {}, metadata = {}) {
143
+ if (this.reportedAnnotateError)
144
+ return;
145
+ this.reportedAnnotateError = true;
146
+ emitDesktopDebug("run.screenshot_annotate.error", debugScope(metadata), debugIds(metadata), {
147
+ actionType: metadata.actionType ?? null,
148
+ x: marker?.x ?? null,
149
+ y: marker?.y ?? null,
150
+ message: error instanceof Error ? error.message : "Failed to annotate screenshot",
151
+ });
152
+ }
153
+ }
@@ -0,0 +1,155 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { buildAssertionSystemPrompt, checkAssertionResult, extractFailureDetails, handleAssertionFailure, handleAssertionSuccess, } from "../../device/assertions.js";
3
+ import { CAPTURE_SOURCES } from "../capture-sources.js";
4
+ import { printCliOutput } from "../../utils/console-output.js";
5
+ import { buildNestedToolDebugContext, buildTextToolOutputItem, isDesignContext, parseStringToolArgument, } from "./utils.js";
6
+ export async function handleAssertionToolCall({ engine, callId, argumentsText, context = null, stepContext = null }) {
7
+ const session = engine.session;
8
+ const parsed = parseStringToolArgument(argumentsText, "condition", "assert");
9
+ if (!parsed.ok) {
10
+ return {
11
+ kind: "continue",
12
+ outputItem: buildTextToolOutputItem(callId, parsed.message),
13
+ };
14
+ }
15
+ const assertionPrompt = parsed.value;
16
+ const transcriptStartIndex = session.transcript.length;
17
+ session.addToTranscript(`[Assertion] ${assertionPrompt}`);
18
+ const screenshotBase64 = await engine.getScreenshotAsBase64(session.deviceId, session.deviceInfo);
19
+ const assertionEventId = randomUUID();
20
+ const assertionScreenshot = await engine.recordScreenshot(screenshotBase64, {
21
+ runId: context?.runId,
22
+ sessionId: context?.sessionId,
23
+ stepId: stepContext?.stepId,
24
+ instructionIndex: stepContext?.instructionIndex,
25
+ eventId: assertionEventId,
26
+ captureSource: CAPTURE_SOURCES.INSTRUCTION_INPUT_ASSERTION,
27
+ });
28
+ const assertionScreenshotPath = assertionScreenshot?.screenshotPath ?? null;
29
+ const assertionScreenshotDataUrl = assertionScreenshot?.screenshotDataUrl ||
30
+ (context?.includeScreenshotDataUrl && assertionScreenshotPath
31
+ ? `data:image/png;base64,${screenshotBase64}`
32
+ : null);
33
+ const assertionEventPayload = {
34
+ eventId: assertionEventId,
35
+ ...(assertionScreenshotPath ? { screenshotPath: assertionScreenshotPath } : {}),
36
+ ...(assertionScreenshotDataUrl ? { screenshotDataUrl: assertionScreenshotDataUrl } : {}),
37
+ };
38
+ const assertionSystemPrompt = buildAssertionSystemPrompt(engine.baseSystemPrompt || session.systemPrompt || "", assertionPrompt);
39
+ const nestedDebugContext = buildNestedToolDebugContext(context, stepContext, "assert", callId);
40
+ const assertionResponse = await engine.sendCUARequest({
41
+ messages: [
42
+ { role: "system", content: assertionSystemPrompt },
43
+ { role: "user", content: `Validate this assertion: ${assertionPrompt}` },
44
+ ],
45
+ screenshotBase64,
46
+ previousResponseId: null,
47
+ deviceInfo: session.deviceInfo,
48
+ debugContext: nestedDebugContext,
49
+ toolCapabilities: {
50
+ assert: false,
51
+ navigate: false,
52
+ loadmill: false,
53
+ },
54
+ });
55
+ // Nested assertion turn: we discard the returned summary because the
56
+ // parent turn (the one that dispatched the assert tool call) is what
57
+ // the caller will read.
58
+ const nestedTurn = await engine.runFullTurn(assertionResponse, () => false, context, stepContext, {
59
+ suppressAssistantOutput: true,
60
+ debugContextOverride: nestedDebugContext,
61
+ currentScreenshot: assertionScreenshot,
62
+ });
63
+ const currentScreenshot = nestedTurn.currentScreenshot ?? assertionScreenshot;
64
+ const assertionTranscript = session.transcript.slice(transcriptStartIndex);
65
+ const result = checkAssertionResult(assertionTranscript);
66
+ if (isDesignContext(context)) {
67
+ const details = result.passed
68
+ ? "Assertion passed."
69
+ : `Assertion failed: ${extractFailureDetails(assertionTranscript)}`;
70
+ if (result.passed) {
71
+ handleAssertionSuccess(assertionPrompt, context, stepContext, assertionEventPayload);
72
+ }
73
+ else {
74
+ const addOutput = context?.addOutput || printCliOutput;
75
+ addOutput({
76
+ type: "error",
77
+ text: `Assertion failed: ${assertionPrompt}`,
78
+ eventType: "assertion_result",
79
+ runId: context?.runId,
80
+ stepId: stepContext?.stepId,
81
+ instructionIndex: stepContext?.instructionIndex,
82
+ payload: {
83
+ assertion: assertionPrompt,
84
+ passed: false,
85
+ details: extractFailureDetails(assertionTranscript),
86
+ ...assertionEventPayload,
87
+ },
88
+ });
89
+ }
90
+ return {
91
+ kind: "continue",
92
+ outputItem: buildTextToolOutputItem(callId, result.passed
93
+ ? `ASSERTION RESULT: PASS\n${details}`
94
+ : `ASSERTION RESULT: FAIL\n${details}`),
95
+ currentScreenshot,
96
+ };
97
+ }
98
+ if (result.failed) {
99
+ handleAssertionFailure(assertionPrompt, assertionTranscript, false, context, stepContext, assertionEventPayload);
100
+ if (engine.isHeadlessMode) {
101
+ return {
102
+ kind: "fail",
103
+ error: `Assertion failed: ${assertionPrompt}`,
104
+ summaryUpdates: { assertionStatus: "failed" },
105
+ };
106
+ }
107
+ const addOutput = context?.addOutput || printCliOutput;
108
+ addOutput({
109
+ type: "system",
110
+ text: "What would you like to do? (retry/skip/stop)",
111
+ eventType: "input_request",
112
+ runId: context?.runId,
113
+ stepId: stepContext?.stepId,
114
+ instructionIndex: stepContext?.instructionIndex,
115
+ payload: {
116
+ options: ["retry", "skip", "stop"],
117
+ },
118
+ });
119
+ const userChoice = await new Promise((resolve) => {
120
+ if (context?.waitForUserInput) {
121
+ context.waitForUserInput().then(resolve);
122
+ }
123
+ else {
124
+ resolve("stop");
125
+ }
126
+ });
127
+ const choice = String(userChoice).toLowerCase().trim();
128
+ if (choice === "retry" || choice === "r") {
129
+ return {
130
+ kind: "retry_instruction",
131
+ summaryUpdates: { assertionStatus: "retry" },
132
+ };
133
+ }
134
+ if (choice === "skip" || choice === "s") {
135
+ return {
136
+ kind: "close_turn",
137
+ outputItem: buildTextToolOutputItem(callId, `ASSERTION RESULT: FAIL\n${extractFailureDetails(assertionTranscript)}`),
138
+ summaryUpdates: { assertionStatus: "failed_skipped" },
139
+ currentScreenshot,
140
+ };
141
+ }
142
+ return {
143
+ kind: "fail",
144
+ error: `Assertion failed: ${assertionPrompt}`,
145
+ summaryUpdates: { assertionStatus: "failed" },
146
+ };
147
+ }
148
+ handleAssertionSuccess(assertionPrompt, context, stepContext, assertionEventPayload);
149
+ return {
150
+ kind: "close_turn",
151
+ outputItem: buildTextToolOutputItem(callId, "ASSERTION RESULT: PASS\nAssertion passed."),
152
+ summaryUpdates: { assertionStatus: "passed" },
153
+ currentScreenshot,
154
+ };
155
+ }
@@ -0,0 +1,29 @@
1
+ import { buildTextToolOutputItem, parseStringToolArgument } from "./utils.js";
2
+ export async function handleLoadmillToolCall({ engine, callId, argumentsText, context = null, stepContext = null }) {
3
+ const session = engine.session;
4
+ const parsed = parseStringToolArgument(argumentsText, "command", "loadmill");
5
+ if (!parsed.ok) {
6
+ return {
7
+ kind: "continue",
8
+ outputItem: buildTextToolOutputItem(callId, parsed.message),
9
+ };
10
+ }
11
+ const loadmillCommand = parsed.value;
12
+ session.addToTranscript(`[Loadmill] ${loadmillCommand}`);
13
+ const result = await engine.executeLoadmillInstruction(loadmillCommand, engine.isHeadlessMode, context, stepContext);
14
+ if (result.retry) {
15
+ return {
16
+ kind: "retry_instruction",
17
+ };
18
+ }
19
+ if (!result.success) {
20
+ return {
21
+ kind: "fail",
22
+ error: result.error || `Loadmill command failed: ${loadmillCommand}`,
23
+ };
24
+ }
25
+ return {
26
+ kind: "close_turn",
27
+ outputItem: buildTextToolOutputItem(callId, "Loadmill command completed successfully."),
28
+ };
29
+ }
@@ -0,0 +1,110 @@
1
+ import { normalizeBrowserInfoForSession } from "../../browser/normalize.js";
2
+ import { buildExecutionNavigationRecoveryPrompt } from "../../prompts/execution.js";
3
+ import { emitDesktopDebug } from "../../utils/desktop-debug.js";
4
+ import { printCliOutput } from "../../utils/console-output.js";
5
+ import { buildDebugContext, buildFunctionCallOutputItem, buildPreviewOutputContent, buildTextToolOutputItem, isDesignContext, parseStringToolArgument, } from "./utils.js";
6
+ export async function handleNavigateToolCall({ engine, callId, argumentsText, context = null, stepContext = null }) {
7
+ const session = engine.session;
8
+ const parsed = parseStringToolArgument(argumentsText, "target", "navigate");
9
+ if (!parsed.ok) {
10
+ return {
11
+ kind: "continue",
12
+ outputItem: buildTextToolOutputItem(callId, parsed.message),
13
+ };
14
+ }
15
+ if (session?.targetKind !== "browser" && session?.deviceInfo?.platform !== "browser") {
16
+ const message = "navigate tool is only supported for web runs.";
17
+ const addOutput = context?.addOutput || printCliOutput;
18
+ addOutput({
19
+ type: "error",
20
+ text: message,
21
+ eventType: "error",
22
+ runId: context?.runId,
23
+ stepId: stepContext?.stepId,
24
+ instructionIndex: stepContext?.instructionIndex,
25
+ payload: {
26
+ instructionType: "navigate",
27
+ message,
28
+ },
29
+ });
30
+ return {
31
+ kind: "fail",
32
+ error: message,
33
+ };
34
+ }
35
+ const navigationTarget = parsed.value;
36
+ const addOutput = context?.addOutput || null;
37
+ session.addToTranscript(`[Navigate] ${navigationTarget}`);
38
+ emitDesktopDebug("runner.navigate.started", isDesignContext(context) ? "design" : "execution", buildDebugContext(context, stepContext), {
39
+ instruction: navigationTarget,
40
+ target: navigationTarget,
41
+ });
42
+ try {
43
+ const normalizedUrl = await engine.navigateToUrl(session.browserSessionId, navigationTarget);
44
+ const browserInfo = normalizeBrowserInfoForSession(await engine.getBrowserInfo(session.browserSessionId));
45
+ session.browserInfo = browserInfo;
46
+ session.deviceInfo = browserInfo;
47
+ session.currentUrl = browserInfo.currentUrl || normalizedUrl;
48
+ session.browserName = browserInfo.browserName || session.currentUrl;
49
+ session.deviceName = session.browserName;
50
+ emitDesktopDebug("runner.navigate.completed", isDesignContext(context) ? "design" : "execution", buildDebugContext(context, stepContext), {
51
+ instruction: navigationTarget,
52
+ target: navigationTarget,
53
+ url: normalizedUrl,
54
+ });
55
+ if (addOutput) {
56
+ addOutput({
57
+ type: "action",
58
+ text: `Navigate to ${normalizedUrl}`,
59
+ eventType: "tool_call",
60
+ runId: context?.runId,
61
+ stepId: stepContext?.stepId,
62
+ instructionIndex: stepContext?.instructionIndex,
63
+ payload: {
64
+ platform: session.deviceInfo?.platform,
65
+ toolName: "navigate",
66
+ target: navigationTarget,
67
+ url: normalizedUrl,
68
+ },
69
+ });
70
+ }
71
+ if (isDesignContext(context)) {
72
+ const screenshotBase64 = await engine.getScreenshotAsBase64(session.deviceId, session.deviceInfo);
73
+ return {
74
+ kind: "continue",
75
+ outputItem: buildFunctionCallOutputItem(callId, buildPreviewOutputContent({
76
+ text: `Navigation complete: ${normalizedUrl}`,
77
+ annotatedScreenshotBase64: screenshotBase64,
78
+ })),
79
+ };
80
+ }
81
+ const transcriptContext = session.getTranscriptText();
82
+ const navigationRecoveryPrompt = buildExecutionNavigationRecoveryPrompt({
83
+ basePrompt: engine.baseSystemPrompt,
84
+ transcript: transcriptContext,
85
+ navigatedUrl: session.currentUrl,
86
+ });
87
+ session.installRecoverySystemPrompt(navigationRecoveryPrompt);
88
+ return {
89
+ kind: "preserve_session_and_stop",
90
+ };
91
+ }
92
+ catch (error) {
93
+ const message = error instanceof Error ? error.message : "Navigation failed.";
94
+ emitDesktopDebug("runner.navigate.failed", isDesignContext(context) ? "design" : "execution", buildDebugContext(context, stepContext), {
95
+ instruction: navigationTarget,
96
+ target: navigationTarget,
97
+ message,
98
+ });
99
+ if (isDesignContext(context)) {
100
+ return {
101
+ kind: "continue",
102
+ outputItem: buildTextToolOutputItem(callId, `Navigation failed: ${message}`),
103
+ };
104
+ }
105
+ return {
106
+ kind: "fail",
107
+ error: message,
108
+ };
109
+ }
110
+ }
@@ -0,0 +1,112 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createAnnotatedScreenshotFromBase64 } from "../../utils/run-screenshot-recorder.js";
3
+ import { CAPTURE_SOURCES } from "../capture-sources.js";
4
+ import { buildFunctionCallOutputItem, buildPreviewOutputContent, } from "./utils.js";
5
+ function buildPreviewClickValidationError(argumentsText) {
6
+ if (!argumentsText || typeof argumentsText !== "string") {
7
+ return "Invalid preview_click arguments: expected a JSON object with numeric x and y.";
8
+ }
9
+ return `Invalid preview_click arguments: ${argumentsText}`;
10
+ }
11
+ function parsePreviewClickArguments(argumentsText) {
12
+ let parsed;
13
+ try {
14
+ parsed = JSON.parse(argumentsText);
15
+ }
16
+ catch {
17
+ return {
18
+ ok: false,
19
+ message: buildPreviewClickValidationError("arguments could not be parsed as JSON; expected numeric x and y."),
20
+ };
21
+ }
22
+ const x = parsed?.x;
23
+ const y = parsed?.y;
24
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
25
+ return {
26
+ ok: false,
27
+ message: buildPreviewClickValidationError("x and y must both be numeric."),
28
+ };
29
+ }
30
+ return { ok: true, x, y };
31
+ }
32
+ function buildPreviewOutputText({ requestedX, requestedY, renderedX, renderedY, wasClamped }) {
33
+ const baseText = `Marker placed at (${renderedX}, ${renderedY}) on the current screen. The click has not been executed.`;
34
+ if (!wasClamped)
35
+ return baseText;
36
+ return `${baseText} Requested point (${requestedX}, ${requestedY}) was off-screen; marker rendered at (${renderedX}, ${renderedY}).`;
37
+ }
38
+ function continueWith(outputItem) {
39
+ return { kind: "continue", outputItem };
40
+ }
41
+ export async function handlePreviewClick({ engine, callId, argumentsText, context = null, stepContext = null }) {
42
+ const session = engine.session;
43
+ const parsedArguments = parsePreviewClickArguments(argumentsText);
44
+ if (!parsedArguments.ok) {
45
+ return continueWith(buildFunctionCallOutputItem(callId, buildPreviewOutputContent({ text: parsedArguments.message })));
46
+ }
47
+ try {
48
+ const screenshotBase64 = await engine.getScreenshotAsBase64(session.deviceId, session.deviceInfo);
49
+ const previewResult = await createAnnotatedScreenshotFromBase64(screenshotBase64, {
50
+ x: parsedArguments.x,
51
+ y: parsedArguments.y,
52
+ style: "preview",
53
+ });
54
+ if (!previewResult?.screenshotBase64 || !previewResult?.marker) {
55
+ return continueWith(buildFunctionCallOutputItem(callId, buildPreviewOutputContent({
56
+ text: "preview_click failed: could not annotate the current screenshot.",
57
+ })));
58
+ }
59
+ const previewEventId = randomUUID();
60
+ const previewScreenshot = await engine.recordScreenshot(previewResult.screenshotBase64, {
61
+ runId: context?.runId,
62
+ sessionId: context?.sessionId,
63
+ stepId: stepContext?.stepId,
64
+ instructionIndex: stepContext?.instructionIndex,
65
+ callId,
66
+ eventId: previewEventId,
67
+ captureSource: CAPTURE_SOURCES.PREVIEW_CLICK,
68
+ });
69
+ const previewText = buildPreviewOutputText({
70
+ requestedX: previewResult.marker.requestedX,
71
+ requestedY: previewResult.marker.requestedY,
72
+ renderedX: previewResult.marker.x,
73
+ renderedY: previewResult.marker.y,
74
+ wasClamped: previewResult.marker.wasClamped,
75
+ });
76
+ const addOutput = context?.addOutput || null;
77
+ if (addOutput) {
78
+ const previewScreenshotDataUrl = context?.includeScreenshotDataUrl
79
+ ? `data:image/png;base64,${previewResult.screenshotBase64}`
80
+ : null;
81
+ addOutput({
82
+ type: "action",
83
+ text: "Click",
84
+ eventType: "tool_call",
85
+ runId: context?.runId,
86
+ stepId: stepContext?.stepId,
87
+ instructionIndex: stepContext?.instructionIndex,
88
+ payload: {
89
+ platform: session.deviceInfo?.platform,
90
+ toolName: "preview_click",
91
+ captureSource: CAPTURE_SOURCES.PREVIEW_CLICK,
92
+ requestedX: previewResult.marker.requestedX,
93
+ requestedY: previewResult.marker.requestedY,
94
+ renderedX: previewResult.marker.x,
95
+ renderedY: previewResult.marker.y,
96
+ wasClamped: previewResult.marker.wasClamped,
97
+ ...(previewScreenshot?.screenshotPath ? { screenshotPath: previewScreenshot.screenshotPath } : {}),
98
+ ...(previewScreenshotDataUrl ? { screenshotDataUrl: previewScreenshotDataUrl } : {}),
99
+ },
100
+ });
101
+ }
102
+ return continueWith(buildFunctionCallOutputItem(callId, buildPreviewOutputContent({
103
+ text: previewText,
104
+ annotatedScreenshotBase64: previewResult.screenshotBase64,
105
+ })));
106
+ }
107
+ catch (error) {
108
+ return continueWith(buildFunctionCallOutputItem(callId, buildPreviewOutputContent({
109
+ text: `preview_click failed: ${error instanceof Error ? error.message : "unable to capture or annotate the current screen."}`,
110
+ })));
111
+ }
112
+ }
@@ -0,0 +1,93 @@
1
+ import { buildDesignScriptGenerationPrompt } from "../../prompts/design.js";
2
+ import { printCliOutput } from "../../utils/console-output.js";
3
+ import { buildTextToolOutputItem, isDesignContext, parseJsonArguments, parseStringArray, } from "./utils.js";
4
+ // Renders the summary as the tool-call output the model sees right after
5
+ // `test_design_complete`. Distinct from buildDesignScriptGenerationPrompt
6
+ // (which goes in a follow-up user message): different headers, different
7
+ // missing-field fallbacks ("(choose automatically)" here vs. "(choose a
8
+ // short kebab-case name)" there).
9
+ function formatDesignCompletionSummary(summary) {
10
+ const lines = [
11
+ "TEST DESIGN COMPLETE",
12
+ `Suggested test name: ${summary.suggestedTestName || "(choose automatically)"}`,
13
+ `Goal summary: ${summary.goalSummary}`,
14
+ `Final state: ${summary.finalState}`,
15
+ "Important steps:",
16
+ ...(summary.importantSteps.length > 0
17
+ ? summary.importantSteps.map((item) => `- ${item}`)
18
+ : ["- (none provided)"]),
19
+ "Verification points:",
20
+ ...(summary.verificationPoints.length > 0
21
+ ? summary.verificationPoints.map((item) => `- ${item}`)
22
+ : ["- (none provided)"]),
23
+ ];
24
+ if (summary.notesForScript) {
25
+ lines.push("Notes for script:");
26
+ lines.push(summary.notesForScript);
27
+ }
28
+ return lines.join("\n");
29
+ }
30
+ export async function handleTestDesignCompleteToolCall({ engine, callId, argumentsText, context = null, stepContext = null }) {
31
+ const session = engine.session;
32
+ if (!isDesignContext(context)) {
33
+ return {
34
+ kind: "continue",
35
+ outputItem: buildTextToolOutputItem(callId, "test_design_complete is only supported in design mode."),
36
+ };
37
+ }
38
+ const parsed = parseJsonArguments(argumentsText);
39
+ if (!parsed.ok || !parsed.value || typeof parsed.value !== "object") {
40
+ return {
41
+ kind: "continue",
42
+ outputItem: buildTextToolOutputItem(callId, "Invalid test_design_complete arguments: expected a JSON object."),
43
+ };
44
+ }
45
+ const summary = {
46
+ suggestedTestName: typeof parsed.value.suggestedTestName === "string" ? parsed.value.suggestedTestName.trim() : "",
47
+ goalSummary: typeof parsed.value.goalSummary === "string" ? parsed.value.goalSummary.trim() : "",
48
+ finalState: typeof parsed.value.finalState === "string" ? parsed.value.finalState.trim() : "",
49
+ importantSteps: parseStringArray(parsed.value.importantSteps),
50
+ verificationPoints: parseStringArray(parsed.value.verificationPoints),
51
+ notesForScript: typeof parsed.value.notesForScript === "string" ? parsed.value.notesForScript.trim() : "",
52
+ };
53
+ if (!summary.goalSummary || !summary.finalState) {
54
+ return {
55
+ kind: "continue",
56
+ outputItem: buildTextToolOutputItem(callId, "Invalid test_design_complete arguments: goalSummary and finalState are required."),
57
+ };
58
+ }
59
+ session.addToTranscript(`[Design Complete] ${summary.goalSummary}`);
60
+ const addOutput = context?.addOutput || printCliOutput;
61
+ addOutput({
62
+ type: "success",
63
+ text: "Design exploration complete. Generating final test script...",
64
+ eventType: "tool_call",
65
+ runId: context?.runId,
66
+ stepId: stepContext?.stepId,
67
+ instructionIndex: stepContext?.instructionIndex,
68
+ payload: {
69
+ toolName: "test_design_complete",
70
+ suggestedTestName: summary.suggestedTestName || null,
71
+ goalSummary: summary.goalSummary,
72
+ finalState: summary.finalState,
73
+ },
74
+ });
75
+ return {
76
+ kind: "continue",
77
+ outputItem: buildTextToolOutputItem(callId, formatDesignCompletionSummary(summary)),
78
+ nextTurnConfig: {
79
+ followUpMessages: [{
80
+ role: "user",
81
+ content: buildDesignScriptGenerationPrompt(summary),
82
+ }],
83
+ toolCapabilitiesOverride: {
84
+ previewClick: false,
85
+ assert: false,
86
+ navigate: false,
87
+ loadmill: false,
88
+ testDesignComplete: false,
89
+ },
90
+ includeComputerToolOverride: false,
91
+ },
92
+ };
93
+ }