Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion src/chrome/src/agent/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -10716,6 +10716,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
mode,
attachments: Array.isArray(runOptions?.traceAttachments) ? runOptions.traceAttachments : [],
conversationId: this.conversationIds.get(tabId) || null,
// Derived-run lineage: when this run is launched from an active run on
// the same tab (cloud run, workflow replay), the launching call site
// passes the origin ids so the trace can attribute the derived run.
parentRunId: runOptions?.parentRunId || null,
parentSessionId: runOptions?.parentSessionId || null,
force: runOptions?.cloudRun === true,
});
} catch {
Expand Down Expand Up @@ -19594,10 +19599,25 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
await trace.endRun(runId, { status, finalContent });
}

async _getSavedWorkflowParentTrace(runId) {
return trace.getRun(runId);
}

async _startSavedWorkflowTraceRun(meta) {
return trace.startRun(meta);
}

async replaySavedWorkflow(tabId, workflow, parameters = {}, onUpdate = () => {}, runOptions = {}) {
if (!workflow?.id || !Array.isArray(workflow.steps) || !workflow.steps.length) {
throw new Error('Saved workflow is missing or invalid.');
}
// Capture the compiling trace before the run claim. Active trace ids are
// cleared when a run ends, but a normalized workflow retains its durable
// source run id and can therefore establish real replay provenance.
const replayParentRunId = runOptions?.parentRunId || workflow.source?.runId || null;
let replayParentSessionId = replayParentRunId
? runOptions?.parentSessionId || null
: null;
await this._claimRunEntry(tabId, 'workflow', runOptions);
let completionRunToken = '';
let previousForegroundCapture = false;
Expand Down Expand Up @@ -19626,8 +19646,16 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
capturePolicyConfigured = true;
startUrl = await this._currentUrl(tabId);
const conversationId = await this.ensureConversationId(tabId, 'act');
traceRunId = await trace.startRun({
if (replayParentRunId && !replayParentSessionId) {
try {
const parentTrace = await this._getSavedWorkflowParentTrace(replayParentRunId);
replayParentSessionId = parentTrace?.conversationId || null;
} catch { /* missing historical traces leave session lineage unset */ }
}
traceRunId = await this._startSavedWorkflowTraceRun({
conversationId,
parentRunId: replayParentRunId,
parentSessionId: replayParentSessionId,
userMessage: `Run saved workflow: ${workflow.name}`,
tabUrl: startUrl,
mode: 'act',
Expand Down
14 changes: 14 additions & 0 deletions src/chrome/src/cloud-runs.js
Original file line number Diff line number Diff line change
Expand Up @@ -1096,6 +1096,18 @@ export function createCloudRunController({
let recordingId = null;
const strictSecretMode = agent.strictSecretMode === true;
try {
// A continuation starts only after its parent has finished, so the
// agent's active-run map cannot identify the parent here. The cloud
// run record keeps the actual trace id; resolve its session from that
// trace instead of attaching the tab's potentially unrelated session.
const parentTraceRunId = parentRun?.traceRunId || null;
let parentTraceSessionId = null;
if (parentTraceRunId && typeof workflowTrace?.getRun === 'function') {
try {
const parentTrace = await workflowTrace.getRun(parentTraceRunId);
parentTraceSessionId = parentTrace?.conversationId || null;
} catch { /* lineage lookup is best-effort and must not fail a run */ }
}
if (run.capture === 'video') {
try {
if (!startRecording || !stopRecording) throw new Error('Cloud run video capture is unavailable.');
Expand Down Expand Up @@ -1169,6 +1181,8 @@ export function createCloudRunController({
run.traceRunId = traceRunId;
schedulePersist();
},
parentRunId: parentTraceRunId,
parentSessionId: parentTraceSessionId,
});
}
run.pendingInput = null;
Expand Down
30 changes: 29 additions & 1 deletion src/firefox/src/agent/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -8577,6 +8577,11 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
mode,
attachments: Array.isArray(runOptions?.traceAttachments) ? runOptions.traceAttachments : [],
conversationId: this.conversationIds.get(tabId) || null,
// Derived-run lineage: when this run is launched from an active run on
// the same tab (cloud run, workflow replay), the launching call site
// passes the origin ids so the trace can attribute the derived run.
parentRunId: runOptions?.parentRunId || null,
parentSessionId: runOptions?.parentSessionId || null,
force: runOptions?.cloudRun === true,
});
} catch {
Expand Down Expand Up @@ -16758,10 +16763,25 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
await trace.endRun(runId, { status, finalContent });
}

async _getSavedWorkflowParentTrace(runId) {
return trace.getRun(runId);
}

async _startSavedWorkflowTraceRun(meta) {
return trace.startRun(meta);
}

async replaySavedWorkflow(tabId, workflow, parameters = {}, onUpdate = () => {}, runOptions = {}) {
if (!workflow?.id || !Array.isArray(workflow.steps) || !workflow.steps.length) {
throw new Error('Saved workflow is missing or invalid.');
}
// Capture the compiling trace before the run claim. Active trace ids are
// cleared when a run ends, but a normalized workflow retains its durable
// source run id and can therefore establish real replay provenance.
const replayParentRunId = runOptions?.parentRunId || workflow.source?.runId || null;
let replayParentSessionId = replayParentRunId
? runOptions?.parentSessionId || null
: null;
await this._claimRunEntry(tabId, 'workflow', runOptions);
let completionRunToken = '';
let startUrl = '';
Expand All @@ -16787,8 +16807,16 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
completionRunToken = this._beginCompletionInvariant(tabId);
startUrl = await this._currentUrl(tabId);
const conversationId = await this.ensureConversationId(tabId, 'act');
traceRunId = await trace.startRun({
if (replayParentRunId && !replayParentSessionId) {
try {
const parentTrace = await this._getSavedWorkflowParentTrace(replayParentRunId);
replayParentSessionId = parentTrace?.conversationId || null;
} catch { /* missing historical traces leave session lineage unset */ }
}
traceRunId = await this._startSavedWorkflowTraceRun({
conversationId,
parentRunId: replayParentRunId,
parentSessionId: replayParentSessionId,
userMessage: `Run saved workflow: ${workflow.name}`,
tabUrl: startUrl,
mode: 'act',
Expand Down
149 changes: 149 additions & 0 deletions test/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -17931,6 +17931,155 @@ test('cloud run controller preserves a boolean false root output schema', async
'persistence marked the false root schema as unstructured');
});

test('cloud run controller threads lineage from the completed parent cloud trace', async () => {
const session = {};
const tab = { id: 23, url: 'https://example.test/', active: true, windowId: 5 };
const receivedRunOptions = [];
let nextRun = 0;
const controller = createCloudRunController({
chromeApi: {
tabs: {
query: async () => [tab],
get: async () => tab,
update: async () => tab,
},
windows: { update: async () => ({}) },
storage: {
session: {
get: async key => ({ [key]: session[key] || [] }),
set: async value => Object.assign(session, value),
},
},
runtime: { sendMessage: async () => ({}) },
},
agent: {
isRunning: () => false,
abort: () => {},
setApiMutationsAllowed: () => {},
setTemporaryApiMutationsAllowed: () => {},
// A persistent conversation id without a trace parent must never become
// an orphaned parent-session link on a root cloud run.
conversationIds: { get: () => 'conv_unrelated' },
processMessage: async (_tabId, task, _onUpdate, _mode, _attachments, runOptions) => {
receivedRunOptions.push(runOptions);
runOptions.onTraceStarted?.(task === 'Parent task' ? 'trace_parent_1' : 'trace_child_1');
return 'cloud result';
},
},
ensureOffscreen: async () => {},
workflowTrace: {
async getRun(runId) {
assert.equal(runId, 'trace_parent_1');
return { runId, conversationId: 'conv_parent_9' };
},
},
makeRunId: () => (++nextRun === 1 ? 'cloud_parent_1' : 'cloud_child_1'),
});

const parent = await controller.startRun({ task: 'Parent task', apiMutationsAllowed: true });
await new Promise(resolve => setTimeout(resolve, 0));
await controller.startRun({ task: 'Child task', parentRunId: parent.runId });
await new Promise(resolve => setTimeout(resolve, 0));

assert.equal(receivedRunOptions[0].parentRunId, null, 'root cloud run received an orphaned parent run');
assert.equal(receivedRunOptions[0].parentSessionId, null, 'root cloud run received an orphaned parent session');
assert.equal(receivedRunOptions[1].parentRunId, 'trace_parent_1', 'continuation did not receive its parent trace');
assert.equal(receivedRunOptions[1].parentSessionId, 'conv_parent_9', 'continuation did not receive its parent trace session');
});

test('cloud run controller tolerates an agent without lineage maps', async () => {
const session = {};
const tab = { id: 24, url: 'https://example.test/', active: true, windowId: 5 };
let receivedRunOptions;
const controller = createCloudRunController({
chromeApi: {
tabs: {
query: async () => [tab],
get: async () => tab,
update: async () => tab,
},
windows: { update: async () => ({}) },
storage: {
session: {
get: async key => ({ [key]: session[key] || [] }),
set: async value => Object.assign(session, value),
},
},
runtime: { sendMessage: async () => ({}) },
},
agent: {
isRunning: () => false,
abort: () => {},
setApiMutationsAllowed: () => {},
setTemporaryApiMutationsAllowed: () => {},
processMessage: async (_tabId, _task, _onUpdate, _mode, _attachments, runOptions) => {
receivedRunOptions = runOptions;
return 'cloud result';
},
},
ensureOffscreen: async () => {},
makeRunId: () => 'run_lineage_root',
});

await controller.startRun({ task: 'Do the thing.', apiMutationsAllowed: true });
assert.equal(receivedRunOptions.parentRunId, null, 'missing parent run must fall back to null');
assert.equal(receivedRunOptions.parentSessionId, null, 'missing parent session must fall back to null');
});

test('trace lineage: _startTraceRun and replay plumb parent ids in both builds', () => {
for (const browser of ['chrome', 'firefox']) {
const agentSource = fs.readFileSync(path.join(ROOT, `src/${browser}/src/agent/agent.js`), 'utf8');
assert.match(agentSource, /parentRunId: runOptions\?\.parentRunId \|\| null,/, `${browser}: _startTraceRun does not forward parentRunId`);
assert.match(agentSource, /parentSessionId: runOptions\?\.parentSessionId \|\| null,/, `${browser}: _startTraceRun does not forward parentSessionId`);
assert.match(agentSource, /const replayParentRunId = runOptions\?\.parentRunId \|\| workflow\.source\?\.runId \|\| null;[\s\S]*?await this\._claimRunEntry\(tabId, 'workflow', runOptions\);/, `${browser}: replay does not capture source lineage before claiming the tab`);
assert.match(agentSource, /this\._getSavedWorkflowParentTrace\(replayParentRunId\)/, `${browser}: replay does not resolve its source trace session`);
assert.match(agentSource, /parentRunId: replayParentRunId,[\s\S]*?parentSessionId: replayParentSessionId,/, `${browser}: replay does not pass captured lineage to tracing`);
}
const chromeCloudRuns = fs.readFileSync(path.join(ROOT, 'src/chrome/src/cloud-runs.js'), 'utf8');
assert.match(chromeCloudRuns, /const parentTraceRunId = parentRun\?\.traceRunId \|\| null;/, 'cloud-runs does not use the completed parent trace');
assert.match(chromeCloudRuns, /workflowTrace\.getRun\(parentTraceRunId\)/, 'cloud-runs does not resolve the parent trace session');
assert.match(chromeCloudRuns, /parentRunId: parentTraceRunId,[\s\S]*?parentSessionId: parentTraceSessionId,/, 'cloud-runs does not thread resolved parent lineage');
assert.ok(!fs.existsSync(path.join(ROOT, 'src/firefox/src/cloud-runs.js')), 'Firefox has no cloud-runs module — lineage threading is Chrome-only by platform boundary');
});

test('saved workflow replay captures its source lineage before claiming the tab', async () => {
for (const [browser, AgentClass] of [['chrome', AgentCh], ['firefox', AgentFx]]) {
const tabId = browser === 'chrome' ? 17853 : 17854;
const workflow = {
id: `workflow_lineage_${browser}`,
name: 'Replay lineage',
source: { runId: `trace_source_${browser}` },
start: { origin: 'https://example.test', pathFamily: '/expected' },
steps: [{ id: 'step_1', tool: 'navigate', args: { url: 'https://example.test/next' } }],
};
const agent = new AgentClass({ getActive: () => ({ model: 'test-model' }) });
let traceMeta = null;
agent._claimRunEntry = async () => {
// If replay reads workflow.source after claiming, this destroys lineage.
workflow.source.runId = '';
};
agent._hydrate = async () => {};
agent._persist = () => {};
agent._currentUrl = async () => 'https://other.test/';
agent.ensureConversationId = async () => `conv_child_${browser}`;
agent._getSavedWorkflowParentTrace = async runId => ({
runId,
conversationId: `conv_parent_${browser}`,
});
agent._startSavedWorkflowTraceRun = async meta => {
traceMeta = meta;
return `trace_replay_${browser}`;
};
agent._endSavedWorkflowTraceRun = async () => {};

const replay = await agent.replaySavedWorkflow(tabId, workflow);

assert.equal(replay.status, 'fallback', `${browser}: replay fixture did not stop after trace start`);
assert.equal(traceMeta.parentRunId, `trace_source_${browser}`, `${browser}: replay lost the compiling trace id`);
assert.equal(traceMeta.parentSessionId, `conv_parent_${browser}`, `${browser}: replay used the child conversation as its parent session`);
}
});

test('cloud run controller rejects duplicate caller-supplied run IDs', async () => {
const session = {};
const controller = createCloudRunController({
Expand Down
Loading