@loadmill/droid-cua 2.10.0 → 2.10.1-rc.9
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.
- package/build/index.js +11 -0
- package/build/src/core/aim-assist.js +1 -1
- package/build/src/core/execution-engine.js +191 -358
- package/build/src/core/prompts.js +1 -1
- package/build/src/core/screenshot-store.js +153 -0
- package/build/src/core/tool-handlers/assert.js +155 -0
- package/build/src/core/tool-handlers/loadmill.js +29 -0
- package/build/src/core/tool-handlers/navigate.js +110 -0
- package/build/src/core/tool-handlers/preview-click.js +112 -0
- package/build/src/core/tool-handlers/test-design-complete.js +93 -0
- package/build/src/core/tool-handlers/utils.js +80 -0
- package/build/src/device/openai.js +101 -8
- package/build/src/modes/design-mode-ink.js +16 -2
- package/build/src/modes/design-mode.js +16 -2
- package/build/src/modes/execution-mode.js +42 -234
- package/build/src/prompts/base.js +20 -21
- package/build/src/prompts/design.js +52 -11
- package/build/src/prompts/editor.js +1 -1
- package/build/src/prompts/execution.js +9 -5
- package/build/src/utils/cua-debug-tracer.js +3 -1
- package/package.json +3 -4
- 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";
|
|
@@ -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
|
+
}
|