surf-cli 2.15.2 → 2.16.1
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.
|
@@ -50,12 +50,16 @@ function modelCandidateMatches(item, targetModel) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
function effortCandidateMatches(item, targetEffort) {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
.flatMap((value) => normalizedWords(value))
|
|
56
|
-
.filter((word) => CHATGPT_EFFORT_CHOICES.includes(word)),
|
|
53
|
+
const labelVariants = new Set(
|
|
54
|
+
normalizedWords(item?.label).filter((word) => CHATGPT_EFFORT_CHOICES.includes(word)),
|
|
57
55
|
);
|
|
58
|
-
|
|
56
|
+
if (labelVariants.size > 0) {
|
|
57
|
+
return labelVariants.size === 1 && labelVariants.has(targetEffort);
|
|
58
|
+
}
|
|
59
|
+
const testIdVariants = new Set(
|
|
60
|
+
normalizedWords(item?.testId).filter((word) => CHATGPT_EFFORT_CHOICES.includes(word)),
|
|
61
|
+
);
|
|
62
|
+
return testIdVariants.size === 1 && testIdVariants.has(targetEffort);
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
function uniqueMatch(items, matches) {
|
|
@@ -191,14 +191,33 @@ async function readPicker(cdp, kind, click = false) {
|
|
|
191
191
|
`(() => {
|
|
192
192
|
${buildClickDispatcher()}
|
|
193
193
|
const kind = ${JSON.stringify(kind)};
|
|
194
|
+
const effortChoices = new Set(${JSON.stringify(CHATGPT_EFFORT_CHOICES)});
|
|
195
|
+
const effortOwnerLabels = new Set([...effortChoices, 'thinking']);
|
|
196
|
+
const normalize = (value) => String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
197
|
+
const labelFor = (node) => {
|
|
198
|
+
const labelledBy = String(node.getAttribute?.('aria-labelledby') || '')
|
|
199
|
+
.split(/\s+/)
|
|
200
|
+
.map((id) => document.getElementById(id)?.textContent || '')
|
|
201
|
+
.join(' ');
|
|
202
|
+
const text = (node.innerText || node.textContent || '').replace(/\s+/g, ' ').trim();
|
|
203
|
+
const aria = (node.getAttribute?.('aria-label') || '').replace(/\s+/g, ' ').trim();
|
|
204
|
+
const title = (node.getAttribute?.('title') || '').replace(/\s+/g, ' ').trim();
|
|
205
|
+
return [text, aria, labelledBy, title].filter(Boolean).join(' | ');
|
|
206
|
+
};
|
|
194
207
|
let nodes = Array.from(document.querySelectorAll(${JSON.stringify(selector)})).filter((node) => {
|
|
195
|
-
const value = ((node
|
|
196
|
-
if (kind !== 'model')
|
|
208
|
+
const value = normalize(labelFor(node));
|
|
209
|
+
if (kind !== 'model') {
|
|
210
|
+
const words = value.split(/\s+/).filter(Boolean);
|
|
211
|
+
const hasEffort = words.some((word) => effortOwnerLabels.has(word));
|
|
212
|
+
const looksLikeModel = node.getAttribute?.('data-testid') === 'model-switcher-dropdown-button' ||
|
|
213
|
+
value.includes('current model') || value.includes('gpt') || value.includes('instant');
|
|
214
|
+
return hasEffort && !looksLikeModel;
|
|
215
|
+
}
|
|
197
216
|
return value.includes('gpt') || value.includes('thinking') || value.includes('instant');
|
|
198
217
|
});
|
|
199
218
|
if (kind === 'model' && nodes.length === 0) {
|
|
200
219
|
nodes = Array.from(document.querySelectorAll(${JSON.stringify(selector)})).filter((node) => {
|
|
201
|
-
const value = ((node
|
|
220
|
+
const value = normalize(labelFor(node));
|
|
202
221
|
return value.includes('pro');
|
|
203
222
|
});
|
|
204
223
|
}
|
|
@@ -208,13 +227,19 @@ async function readPicker(cdp, kind, click = false) {
|
|
|
208
227
|
);
|
|
209
228
|
}
|
|
210
229
|
const items = nodes.map((node) => {
|
|
211
|
-
const text = (node.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
230
|
+
const text = (node.innerText || node.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
212
231
|
const aria = (node.getAttribute?.('aria-label') || '').replace(/\\s+/g, ' ').trim();
|
|
232
|
+
const labelledBy = String(node.getAttribute?.('aria-labelledby') || '')
|
|
233
|
+
.split(/\\s+/)
|
|
234
|
+
.map((id) => document.getElementById(id)?.textContent || '')
|
|
235
|
+
.join(' ')
|
|
236
|
+
.replace(/\\s+/g, ' ')
|
|
237
|
+
.trim();
|
|
213
238
|
const title = (node.getAttribute?.('title') || '').replace(/\\s+/g, ' ').trim();
|
|
214
239
|
return {
|
|
215
240
|
role: node.getAttribute?.('role') || (node.tagName === 'BUTTON' ? 'button' : null),
|
|
216
|
-
label: [text, aria, title].filter(Boolean).join(' | ').slice(0, 240),
|
|
217
|
-
displayLabel: (text || aria || title).slice(0, 80),
|
|
241
|
+
label: [text, aria, labelledBy, title].filter(Boolean).join(' | ').slice(0, 240),
|
|
242
|
+
displayLabel: (text || aria || labelledBy || title).slice(0, 80),
|
|
218
243
|
testId: node.getAttribute?.('data-testid') || null,
|
|
219
244
|
};
|
|
220
245
|
});
|
|
@@ -370,6 +395,10 @@ async function selectEffort(cdp, desiredEffort, timeoutMs = 8000, signal) {
|
|
|
370
395
|
throwIfAborted(signal);
|
|
371
396
|
const normalizedEffort = normalizeChatGPTEffortChoice(desiredEffort);
|
|
372
397
|
if (!normalizedEffort) throw verificationError("effort", desiredEffort, [], true);
|
|
398
|
+
const current = await readPicker(cdp, "effort");
|
|
399
|
+
const currentVerified = verifyChatGPTEffortSelection(current?.items, normalizedEffort);
|
|
400
|
+
if (currentVerified) return currentVerified.displayLabel || currentVerified.label;
|
|
401
|
+
|
|
373
402
|
const picker = await readPicker(cdp, "effort", true);
|
|
374
403
|
if (picker?.items?.length !== 1) throw verificationError("effort", desiredEffort);
|
|
375
404
|
await delay(300, signal);
|
|
@@ -407,6 +436,13 @@ async function typePrompt(cdp, inputCdp, prompt, signal) {
|
|
|
407
436
|
const node = document.querySelector(selector);
|
|
408
437
|
if (!node) continue;
|
|
409
438
|
dispatchClickSequence(node);
|
|
439
|
+
if ('value' in node) {
|
|
440
|
+
node.value = '';
|
|
441
|
+
node.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' }));
|
|
442
|
+
} else {
|
|
443
|
+
node.textContent = '';
|
|
444
|
+
node.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' }));
|
|
445
|
+
}
|
|
410
446
|
if (typeof node.focus === 'function') node.focus();
|
|
411
447
|
const doc = node.ownerDocument;
|
|
412
448
|
const selection = doc?.getSelection?.();
|
|
@@ -152,10 +152,6 @@ async function dispatch(options) {
|
|
|
152
152
|
modelVerified = await selectModel(cdp, model, 8000, signal);
|
|
153
153
|
log(`Verified model: ${modelVerified}`);
|
|
154
154
|
}
|
|
155
|
-
if (effort) {
|
|
156
|
-
effortVerified = await selectEffort(cdp, effort, 8000, signal);
|
|
157
|
-
log(`Verified effort: ${effortVerified}`);
|
|
158
|
-
}
|
|
159
155
|
if (file) {
|
|
160
156
|
if (!uploadFile) {
|
|
161
157
|
throw new Error(
|
|
@@ -177,6 +173,10 @@ async function dispatch(options) {
|
|
|
177
173
|
}
|
|
178
174
|
await typePrompt(cdp, inputCdp, prompt, signal);
|
|
179
175
|
log("Prompt typed");
|
|
176
|
+
if (effort) {
|
|
177
|
+
effortVerified = await selectEffort(cdp, effort, 8000, signal);
|
|
178
|
+
log(`Verified effort: ${effortVerified}`);
|
|
179
|
+
}
|
|
180
180
|
const baseline = normalizeResponseSnapshot(await readChatGPTResponseSnapshot(cdp));
|
|
181
181
|
if (beforeSubmit) await raceAbort(beforeSubmit, signal);
|
|
182
182
|
await clickSend(cdp, inputCdp, signal);
|
package/native/oracle-host.cjs
CHANGED
|
@@ -71,7 +71,9 @@ function createOracleHost({ queueAiRequest, requestCallExtension, buildProviderU
|
|
|
71
71
|
model,
|
|
72
72
|
effortRequested: args.effort ?? null,
|
|
73
73
|
follow: args.follow ?? null,
|
|
74
|
+
requestId: args.requestId ?? null,
|
|
74
75
|
});
|
|
76
|
+
if (created.requestDeduped) return oracleJobs.getJob(created.id);
|
|
75
77
|
let createdTabId = null;
|
|
76
78
|
|
|
77
79
|
try {
|
|
@@ -109,6 +111,8 @@ function createOracleHost({ queueAiRequest, requestCallExtension, buildProviderU
|
|
|
109
111
|
oracleJobs.appendTurn(parent.id, {
|
|
110
112
|
prompt: args.prompt,
|
|
111
113
|
dispatchedAt: dispatchedJob.dispatchedAt,
|
|
114
|
+
childJobId: created.id,
|
|
115
|
+
requestId: args.requestId ?? null,
|
|
112
116
|
});
|
|
113
117
|
}
|
|
114
118
|
},
|
|
@@ -259,6 +263,8 @@ function createOracleHost({ queueAiRequest, requestCallExtension, buildProviderU
|
|
|
259
263
|
oracleJobs.markTurnCaptured(captured.follow, {
|
|
260
264
|
dispatchedAt: captured.dispatchedAt,
|
|
261
265
|
capturedAt: captured.capturedAt,
|
|
266
|
+
childJobId: captured.id,
|
|
267
|
+
requestId: captured.requestId ?? null,
|
|
262
268
|
});
|
|
263
269
|
}
|
|
264
270
|
if (captured.tabId) {
|
package/native/oracle-jobs.cjs
CHANGED
|
@@ -39,6 +39,32 @@ function promptDigest(prompt) {
|
|
|
39
39
|
return `sha256:${crypto.createHash("sha256").update(prompt).digest("hex")}`;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
function stableJson(value) {
|
|
43
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
44
|
+
if (value && typeof value === "object") {
|
|
45
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
|
|
46
|
+
}
|
|
47
|
+
return JSON.stringify(value) ?? "null";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function requestFingerprint({ prompt, contextManifest, model, effortRequested, follow }) {
|
|
51
|
+
return promptDigest(stableJson({
|
|
52
|
+
promptDigest: promptDigest(prompt),
|
|
53
|
+
contextManifest: contextManifest ?? {},
|
|
54
|
+
model: model ?? null,
|
|
55
|
+
effortRequested: effortRequested ?? null,
|
|
56
|
+
follow: follow ?? null,
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizedRequestId(requestId) {
|
|
61
|
+
if (requestId === null || requestId === undefined) return null;
|
|
62
|
+
if (typeof requestId !== "string" || !requestId.trim() || requestId.trim() !== requestId || requestId.length > 256 || requestId.includes("\0")) {
|
|
63
|
+
throw codedError("invalid_request", "oracle requestId must be a non-empty trimmed string");
|
|
64
|
+
}
|
|
65
|
+
return requestId;
|
|
66
|
+
}
|
|
67
|
+
|
|
42
68
|
function hydrateJobMetadata(job, root = getPrivateStateRoot()) {
|
|
43
69
|
const prompt = job.promptDigest ? null : readPrivateFile(path.join(jobDirectory(job.id, root), "request.md"), {
|
|
44
70
|
root,
|
|
@@ -66,10 +92,27 @@ function readJobs(root = getPrivateStateRoot()) {
|
|
|
66
92
|
.map((job) => hydrateJobMetadata(job, root));
|
|
67
93
|
}
|
|
68
94
|
|
|
69
|
-
function createJob({ prompt, contextManifest = {}, model = null, effortRequested = null, follow = null }) {
|
|
95
|
+
function createJob({ prompt, contextManifest = {}, model = null, effortRequested = null, follow = null, requestId = null }) {
|
|
70
96
|
const root = getPrivateStateRoot();
|
|
71
97
|
const base = oracleRoot(root);
|
|
72
98
|
ensurePrivateDir(base, root);
|
|
99
|
+
const safeRequestId = normalizedRequestId(requestId);
|
|
100
|
+
const fingerprint = safeRequestId
|
|
101
|
+
? requestFingerprint({ prompt, contextManifest, model, effortRequested, follow })
|
|
102
|
+
: null;
|
|
103
|
+
if (safeRequestId) {
|
|
104
|
+
const existing = readJobs(root).find((job) => job.requestId === safeRequestId);
|
|
105
|
+
if (existing) {
|
|
106
|
+
if (existing.requestFingerprint !== fingerprint) {
|
|
107
|
+
throw codedError(
|
|
108
|
+
"idempotency_conflict",
|
|
109
|
+
`oracle requestId ${safeRequestId} was already used for a different request`,
|
|
110
|
+
{ jobId: existing.id },
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return { ...existing, requestDeduped: true };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
73
116
|
const inFlight = readJobs(root).find((job) => !TERMINAL_STATES.has(job.state));
|
|
74
117
|
if (inFlight) {
|
|
75
118
|
throw codedError(
|
|
@@ -108,6 +151,7 @@ function createJob({ prompt, contextManifest = {}, model = null, effortRequested
|
|
|
108
151
|
effortRequested,
|
|
109
152
|
effortVerified: null,
|
|
110
153
|
promptDigest: promptDigest(prompt),
|
|
154
|
+
...(safeRequestId ? { requestId: safeRequestId, requestFingerprint: fingerprint } : {}),
|
|
111
155
|
createdAt: now.toISOString(),
|
|
112
156
|
dispatchedAt: null,
|
|
113
157
|
awaitingAt: null,
|
|
@@ -215,10 +259,14 @@ function updateTabId(id, tabId) {
|
|
|
215
259
|
|
|
216
260
|
function appendTurn(id, turn) {
|
|
217
261
|
const job = getJob(id);
|
|
262
|
+
const duplicate = job.turns.find((existing) => (turn.childJobId && existing.childJobId === turn.childJobId) || (turn.requestId && existing.requestId === turn.requestId));
|
|
263
|
+
if (duplicate) return job;
|
|
218
264
|
const storedTurn = {
|
|
219
265
|
prompt: turn.prompt,
|
|
220
266
|
dispatchedAt: turn.dispatchedAt ?? null,
|
|
221
267
|
capturedAt: turn.capturedAt ?? null,
|
|
268
|
+
...(turn.childJobId ? { childJobId: turn.childJobId } : {}),
|
|
269
|
+
...(turn.requestId ? { requestId: turn.requestId } : {}),
|
|
222
270
|
};
|
|
223
271
|
const root = getPrivateStateRoot();
|
|
224
272
|
const directory = jobDirectory(id, root);
|
|
@@ -229,9 +277,9 @@ function appendTurn(id, turn) {
|
|
|
229
277
|
return updated;
|
|
230
278
|
}
|
|
231
279
|
|
|
232
|
-
function markTurnCaptured(id, { dispatchedAt, capturedAt }) {
|
|
280
|
+
function markTurnCaptured(id, { dispatchedAt, capturedAt, childJobId, requestId }) {
|
|
233
281
|
const job = getJob(id);
|
|
234
|
-
const turnIndex = job.turns.findIndex((turn) => turn.dispatchedAt === dispatchedAt);
|
|
282
|
+
const turnIndex = job.turns.findIndex((turn) => (childJobId && turn.childJobId === childJobId) || (requestId && turn.requestId === requestId) || turn.dispatchedAt === dispatchedAt);
|
|
235
283
|
if (turnIndex === -1) {
|
|
236
284
|
throw codedError(
|
|
237
285
|
"invalid_transition",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "surf-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.1",
|
|
4
4
|
"description": "CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"chrome",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"@vitest/coverage-v8": "^4.1.9",
|
|
72
72
|
"@vitest/ui": "^4.1.9",
|
|
73
73
|
"pi-subagents": "^0.52.0",
|
|
74
|
-
"puppeteer": "25.
|
|
74
|
+
"puppeteer": "25.8.0",
|
|
75
75
|
"typebox": "^1.3.11",
|
|
76
76
|
"typescript": "^7.0.2",
|
|
77
77
|
"vite": "^8.1.4",
|
package/pi-extension/surf.ts
CHANGED
|
@@ -43,15 +43,10 @@ type ToolResult = { content: Array<{ type: "text" | "image"; text?: string; data
|
|
|
43
43
|
type OracleJob = {
|
|
44
44
|
id: string;
|
|
45
45
|
state: string;
|
|
46
|
-
conversationUrl
|
|
47
|
-
|
|
48
|
-
modelRequested?: string | null;
|
|
49
|
-
modelVerified?: string | null;
|
|
50
|
-
effortRequested?: string | null;
|
|
51
|
-
effortVerified?: string | null;
|
|
52
|
-
promptDigest?: string | null;
|
|
46
|
+
conversationUrl: string | null;
|
|
47
|
+
follow: string | null;
|
|
53
48
|
response?: string;
|
|
54
|
-
error
|
|
49
|
+
error: { code?: string; message?: string } | null;
|
|
55
50
|
};
|
|
56
51
|
|
|
57
52
|
type BackgroundWorkProvider = {
|
|
@@ -75,6 +70,7 @@ type OracleExternalJob = {
|
|
|
75
70
|
id: string;
|
|
76
71
|
state: string;
|
|
77
72
|
conversationUrl: string | null;
|
|
73
|
+
follow?: string;
|
|
78
74
|
resultText?: string;
|
|
79
75
|
failure?: { code: string; message: string };
|
|
80
76
|
};
|
|
@@ -97,6 +93,7 @@ type OracleExternalJobProvider = {
|
|
|
97
93
|
status(id: string): Promise<PiExternalJobHandle>;
|
|
98
94
|
result(id: string): Promise<PiExternalJobResult>;
|
|
99
95
|
reattach(id: string): Promise<PiExternalJobHandle>;
|
|
96
|
+
followUp?(input: Record<string, unknown>): Promise<PiExternalJobHandle>;
|
|
100
97
|
};
|
|
101
98
|
|
|
102
99
|
type RegisterExternalJobProvider = (provider: OracleExternalJobProvider) => () => void;
|
|
@@ -257,11 +254,40 @@ export async function resolveExternalJobProviderRegister(
|
|
|
257
254
|
return registerGlobalExternalJobProvider;
|
|
258
255
|
}
|
|
259
256
|
|
|
257
|
+
function optionalOracleString(value: unknown, field: string): string | undefined {
|
|
258
|
+
if (value === undefined || value === null) return undefined;
|
|
259
|
+
if (typeof value !== "string") throw new Error(`Surf oracle response included an invalid ${field}`);
|
|
260
|
+
return value;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function oracleFailure(value: unknown): OracleJob["error"] {
|
|
264
|
+
if (value === undefined || value === null) return null;
|
|
265
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
266
|
+
throw new Error("Surf oracle response included invalid failure details");
|
|
267
|
+
}
|
|
268
|
+
const details = value as Record<string, unknown>;
|
|
269
|
+
const code = optionalOracleString(details.code, "failure code");
|
|
270
|
+
const message = optionalOracleString(details.message, "failure message");
|
|
271
|
+
return { ...(code === undefined ? {} : { code }), ...(message === undefined ? {} : { message }) };
|
|
272
|
+
}
|
|
273
|
+
|
|
260
274
|
function asOracleJob(value: unknown): OracleJob {
|
|
261
275
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Surf oracle response did not include job metadata");
|
|
262
|
-
const job = value as
|
|
263
|
-
if (typeof job.id !== "string" ||
|
|
264
|
-
|
|
276
|
+
const job = value as Record<string, unknown>;
|
|
277
|
+
if (typeof job.id !== "string" || !job.id || job.id.trim() !== job.id || typeof job.state !== "string") {
|
|
278
|
+
throw new Error("Surf oracle response did not include a valid job id and state");
|
|
279
|
+
}
|
|
280
|
+
const conversationUrl = optionalOracleString(job.conversationUrl, "conversation URL");
|
|
281
|
+
const follow = optionalOracleString(job.follow, "follow job id");
|
|
282
|
+
const response = optionalOracleString(job.response, "result text");
|
|
283
|
+
return {
|
|
284
|
+
id: job.id,
|
|
285
|
+
state: job.state,
|
|
286
|
+
conversationUrl: conversationUrl ?? null,
|
|
287
|
+
follow: follow ?? null,
|
|
288
|
+
error: oracleFailure(job.error),
|
|
289
|
+
...(response === undefined ? {} : { response }),
|
|
290
|
+
};
|
|
265
291
|
}
|
|
266
292
|
|
|
267
293
|
function oracleExternalJob(job: OracleJob): OracleExternalJob {
|
|
@@ -273,6 +299,7 @@ function oracleExternalJob(job: OracleJob): OracleExternalJob {
|
|
|
273
299
|
id: job.id,
|
|
274
300
|
state: job.state,
|
|
275
301
|
conversationUrl: job.conversationUrl ?? null,
|
|
302
|
+
...(typeof job.follow === "string" ? { follow: job.follow } : {}),
|
|
276
303
|
...(resultText === undefined ? {} : { resultText }),
|
|
277
304
|
...(failure ? { failure } : {}),
|
|
278
305
|
};
|
|
@@ -333,6 +360,7 @@ async function requestOracleJob(request: typeof requestSurf, tool: string, args:
|
|
|
333
360
|
}
|
|
334
361
|
|
|
335
362
|
function emitFailedOracleJob(error: unknown, emitTerminal: EmitOracleJob) {
|
|
363
|
+
if (error && typeof error === "object" && "code" in error && error.code === "SURF_REQUEST_ABORTED") return;
|
|
336
364
|
if (!error || typeof error !== "object" || !("jobId" in error) || typeof error.jobId !== "string") return;
|
|
337
365
|
emitTerminal({ id: error.jobId, state: "failed" });
|
|
338
366
|
}
|
|
@@ -347,8 +375,41 @@ function oracleOption(input: Record<string, unknown>, key: "model" | "effort"):
|
|
|
347
375
|
return typeof direct === "string" ? direct : undefined;
|
|
348
376
|
}
|
|
349
377
|
|
|
378
|
+
function optionalString(input: Record<string, unknown>, key: string): string | undefined {
|
|
379
|
+
const value = input[key];
|
|
380
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function parentProviderJobId(input: Record<string, unknown>): string {
|
|
384
|
+
const id = optionalString(input, "parentProviderJobId") ?? optionalString(input, "providerJobId");
|
|
385
|
+
if (!id) throw new Error("parentProviderJobId required");
|
|
386
|
+
return id;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function assertFollowJob(job: OracleExternalJob, parentId: string) {
|
|
390
|
+
if (job.id === parentId) throw new Error(`Surf oracle follow-up reused parent job '${parentId}'`);
|
|
391
|
+
if (job.follow !== parentId) throw new Error(`Surf oracle follow-up job '${job.id}' is not linked to parent '${parentId}'`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const ORACLE_STATUS_HARVEST_TIMEOUT_SECONDS = 5;
|
|
395
|
+
|
|
396
|
+
async function requestOracleJobStatus(request: typeof requestSurf, id: string) {
|
|
397
|
+
try {
|
|
398
|
+
return await requestOracleJob(request, "oracle.result", {
|
|
399
|
+
id,
|
|
400
|
+
timeout: ORACLE_STATUS_HARVEST_TIMEOUT_SECONDS,
|
|
401
|
+
});
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if (!error || typeof error !== "object") throw error;
|
|
404
|
+
if ("code" in error && error.code === "SURF_REQUEST_ABORTED") throw error;
|
|
405
|
+
if ("jobId" in error && error.jobId === id) {
|
|
406
|
+
return requestOracleJob(request, "oracle.status", { id });
|
|
407
|
+
}
|
|
408
|
+
throw error;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
350
412
|
export function createOracleExternalJobProvider(
|
|
351
|
-
sessionId: string,
|
|
352
413
|
jobIds: Set<string>,
|
|
353
414
|
request: typeof requestSurf = requestSurf,
|
|
354
415
|
rememberJob: RememberOracleJob = (jobId) => {
|
|
@@ -356,8 +417,9 @@ export function createOracleExternalJobProvider(
|
|
|
356
417
|
return true;
|
|
357
418
|
},
|
|
358
419
|
emitTerminal: EmitOracleJob = () => false,
|
|
420
|
+
options: { followUp?: boolean } = {},
|
|
359
421
|
): OracleExternalJobProvider {
|
|
360
|
-
|
|
422
|
+
const provider: OracleExternalJobProvider = {
|
|
361
423
|
name: "surf-oracle",
|
|
362
424
|
async start(input) {
|
|
363
425
|
const prompt = typeof input.prompt === "string" ? input.prompt : "";
|
|
@@ -373,7 +435,9 @@ export function createOracleExternalJobProvider(
|
|
|
373
435
|
return piExternalJobHandle(job);
|
|
374
436
|
},
|
|
375
437
|
async status(id) {
|
|
376
|
-
|
|
438
|
+
const job = await requestOracleJobStatus(request, id);
|
|
439
|
+
emitTerminal({ id: job.id, state: job.state });
|
|
440
|
+
return piExternalJobHandle(job);
|
|
377
441
|
},
|
|
378
442
|
result(id) {
|
|
379
443
|
return requestOracleJob(request, "oracle.result", { id })
|
|
@@ -387,7 +451,7 @@ export function createOracleExternalJobProvider(
|
|
|
387
451
|
});
|
|
388
452
|
},
|
|
389
453
|
reattach(id) {
|
|
390
|
-
return
|
|
454
|
+
return requestOracleJobStatus(request, id)
|
|
391
455
|
.then((job) => {
|
|
392
456
|
rememberJob(job.id);
|
|
393
457
|
emitTerminal({ id: job.id, state: job.state });
|
|
@@ -399,6 +463,27 @@ export function createOracleExternalJobProvider(
|
|
|
399
463
|
});
|
|
400
464
|
},
|
|
401
465
|
};
|
|
466
|
+
if (options.followUp) {
|
|
467
|
+
provider.followUp = async (input) => {
|
|
468
|
+
const prompt = typeof input.prompt === "string" ? input.prompt : "";
|
|
469
|
+
if (!prompt.trim()) throw new Error("prompt required");
|
|
470
|
+
const parentId = parentProviderJobId(input);
|
|
471
|
+
const model = oracleOption(input, "model");
|
|
472
|
+
const effort = oracleOption(input, "effort");
|
|
473
|
+
const requestId = optionalString(input, "requestId");
|
|
474
|
+
const job = await requestOracleJob(request, "oracle.ask", {
|
|
475
|
+
prompt,
|
|
476
|
+
follow: parentId,
|
|
477
|
+
...(model !== undefined ? { model } : {}),
|
|
478
|
+
...(effort !== undefined ? { effort } : {}),
|
|
479
|
+
...(requestId !== undefined ? { requestId } : {}),
|
|
480
|
+
});
|
|
481
|
+
assertFollowJob(job, parentId);
|
|
482
|
+
rememberJob(job.id);
|
|
483
|
+
return piExternalJobHandle(job);
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
return provider;
|
|
402
487
|
}
|
|
403
488
|
|
|
404
489
|
export function registerOptionalBackgroundProvider(sessionId: string, jobIds: Set<string>, listJobs: () => Array<{ id: string; state: string }>, register: RegisterBackgroundWorkProvider) {
|
|
@@ -412,14 +497,21 @@ export function registerOptionalBackgroundProvider(sessionId: string, jobIds: Se
|
|
|
412
497
|
}
|
|
413
498
|
|
|
414
499
|
export function registerOptionalExternalJobProvider(
|
|
415
|
-
sessionId: string,
|
|
416
500
|
jobIds: Set<string>,
|
|
417
501
|
register: RegisterExternalJobProvider,
|
|
418
502
|
request: typeof requestSurf = requestSurf,
|
|
419
503
|
rememberJob?: RememberOracleJob,
|
|
420
504
|
emitTerminal?: EmitOracleJob,
|
|
421
505
|
) {
|
|
422
|
-
|
|
506
|
+
const provider = createOracleExternalJobProvider(jobIds, request, rememberJob, emitTerminal, { followUp: true });
|
|
507
|
+
try {
|
|
508
|
+
return register(provider);
|
|
509
|
+
} catch (error) {
|
|
510
|
+
if (String(error instanceof Error ? error.message : error).includes("followUp")) {
|
|
511
|
+
return register(createOracleExternalJobProvider(jobIds, request, rememberJob, emitTerminal));
|
|
512
|
+
}
|
|
513
|
+
throw error;
|
|
514
|
+
}
|
|
423
515
|
}
|
|
424
516
|
|
|
425
517
|
export function rememberOracleJobForSession(jobIds: Set<string>, jobId: unknown, requestGeneration: number, currentGeneration: number, sessionActive: boolean): boolean {
|
|
@@ -533,7 +625,7 @@ export default function surfExtension(pi: Pi) {
|
|
|
533
625
|
const rememberForGeneration = (jobId: string) => rememberOracleJobForSession(oracleJobIds, jobId, generation, sessionGeneration, sessionActive);
|
|
534
626
|
const emitFinished = (job: Pick<OracleExternalJob, "id" | "state">) => emitOracleFinished(pi, job);
|
|
535
627
|
dispose = registerOptionalBackgroundProvider(sessionId, oracleJobIds, jobs.listJobs, registerGlobalBackgroundProvider);
|
|
536
|
-
disposeExternal = registerOptionalExternalJobProvider(
|
|
628
|
+
disposeExternal = registerOptionalExternalJobProvider(oracleJobIds, registerGlobalExternalJobProvider, requestSurf, rememberForGeneration, emitFinished);
|
|
537
629
|
sessionActive = true;
|
|
538
630
|
void resolveBackgroundWorkRegister().then((register) => {
|
|
539
631
|
try {
|
|
@@ -552,7 +644,7 @@ export default function surfExtension(pi: Pi) {
|
|
|
552
644
|
void resolveExternalJobProviderRegister().then((register) => {
|
|
553
645
|
try {
|
|
554
646
|
if (register === registerGlobalExternalJobProvider || generation !== sessionGeneration) return;
|
|
555
|
-
const nextDispose = registerOptionalExternalJobProvider(
|
|
647
|
+
const nextDispose = registerOptionalExternalJobProvider(oracleJobIds, register, requestSurf, rememberForGeneration, emitFinished);
|
|
556
648
|
if (generation !== sessionGeneration) {
|
|
557
649
|
nextDispose();
|
|
558
650
|
return;
|