Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
156 changes: 147 additions & 9 deletions actions/setup/js/agent_session.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ function isMetric(value) {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}

/** @param {any} event @returns {boolean} */
function isNestedSessionEvent(event) {
if (event == null) return false;
return [event.agentId, event.data?.agentId, event.parent_tool_use_id, event.parentToolUseId, event.parentToolCallId, event.data?.parentToolUseId, event.data?.parent_tool_use_id, event.data?.parentToolCallId].some(
value => typeof value === "string" && value.length > 0
);
}

/**
* Subagent lifecycle envelope IDs identify the target, not the emitting caller.
* @param {any} event
Expand All @@ -42,7 +50,17 @@ function sessionContext(event) {
const data = event?.data ?? {};
const sessionId = data.sessionId !== undefined ? data.sessionId : event?.sessionId !== undefined ? event.sessionId : event?.session_id !== undefined ? event.session_id : data.session_id;
const parentToolUseId =
data.parentToolUseId !== undefined ? data.parentToolUseId : event?.parentToolUseId !== undefined ? event.parentToolUseId : event?.parent_tool_use_id !== undefined ? event.parent_tool_use_id : data.parent_tool_use_id;
data.parentToolUseId !== undefined
? data.parentToolUseId
: event?.parentToolUseId !== undefined
? event.parentToolUseId
: event?.parentToolCallId !== undefined
? event.parentToolCallId
: event?.parent_tool_use_id !== undefined
? event.parent_tool_use_id
: data.parent_tool_use_id !== undefined
? data.parent_tool_use_id
: data.parentToolCallId;
const agentId = data.agentId ?? (typeof event?.type === "string" && event.type.startsWith("subagent.") ? undefined : event?.agentId);
return {
...(typeof sessionId === "string" || sessionId === null ? { sessionId } : {}),
Expand All @@ -51,15 +69,133 @@ function sessionContext(event) {
};
}

/** @param {any} event @returns {string} */
function sessionScopeKey(event) {
const { sessionId, parentToolUseId, agentId } = sessionContext(event);
return JSON.stringify([sessionId, parentToolUseId, agentId]);
}

/** @param {Array<any>} events @returns {boolean} */
function isSingleNestedSession(events) {
return events.length > 0 && events.every(event => !!sessionContext(event).parentToolUseId) && new Set(events.map(sessionScopeKey)).size === 1;
}

/**
* Resolve lifecycle-backed child identity without guessing when IDs are ambiguous.
* @param {any} event
* @param {string} sourceIdentity
* @param {string|undefined} rootSessionId
* @param {Map<string, Map<any, any>>} instancesByAgent
* @param {Map<string, Set<any>>} agentsBySpawningTool
*/
function resolveAgentIdentity(event, sourceIdentity, rootSessionId, instancesByAgent, agentsBySpawningTool) {
const data = event.data ?? {};
let agentId = event.agentId || data.agentId || undefined;
const parentToolUseId = sessionContext(event).parentToolUseId;
let parentToolCallId = event.parentToolCallId || data.parentToolCallId || event.parent_tool_use_id || event.parentToolUseId || data.parentToolUseId || data.parent_tool_use_id || parentToolUseId || undefined;

if (event.type === "subagent.started" && agentId !== undefined) {
parentToolCallId = data.toolCallId ?? parentToolCallId;
const agentIdentity = JSON.stringify([sourceIdentity, rootSessionId, agentId]);
const instances = instancesByAgent.get(agentIdentity) ?? new Map();
instances.set(parentToolCallId, { parentToolCallId, parentAgentId: data.parentAgentId ?? data.parentId });
instancesByAgent.set(agentIdentity, instances);
if (parentToolCallId !== undefined) {
const spawningToolIdentity = JSON.stringify([sourceIdentity, rootSessionId, parentToolCallId]);
const candidates = agentsBySpawningTool.get(spawningToolIdentity) ?? new Set();
candidates.add(agentId);
agentsBySpawningTool.set(spawningToolIdentity, candidates);
}
}

let ambiguous = false;
if (agentId === undefined && parentToolCallId !== undefined) {
const candidates = agentsBySpawningTool.get(JSON.stringify([sourceIdentity, rootSessionId, parentToolCallId]));
if (candidates?.size === 1) agentId = candidates.values().next().value;
else if (candidates && candidates.size > 1) ambiguous = true;
}

const instances = agentId === undefined ? undefined : instancesByAgent.get(JSON.stringify([sourceIdentity, rootSessionId, agentId]));
let agent;
if (instances) {
if (parentToolCallId !== undefined) agent = instances.get(parentToolCallId);
if (!agent && instances.size === 1) {
agent = instances.values().next().value;
parentToolCallId = agent.parentToolCallId;
} else if (!agent && instances.size > 1) ambiguous = true;
}

return {
agentId,
parentToolUseId,
parentToolCallId,
parentAgentId: data.parentAgentId ?? agent?.parentAgentId,
ambiguous,
instanceScoped: instances !== undefined,
};
}

/** @param {any} event @returns {string} */
function sessionScopeKey(event) {
const { sessionId, parentToolUseId, agentId } = sessionContext(event);
return JSON.stringify([sessionId, parentToolUseId, agentId]);
function sourceIdentity(event) {
return JSON.stringify([event.provenance?.component, event.provenance?.phase, event.provenance?.path]);
}

/** @param {Array<any>} events @returns {Array<string|undefined>} */
function rootSessionIdsFor(events) {
const rootStartsBySource = new Map();
for (const [index, event] of events.entries()) {
const data = event.data ?? {};
const source = sourceIdentity(event);
const explicitSessionId = event.session_id ?? event.sessionId ?? data.sessionId ?? data.session_id;
if (["session.start", "session.init"].includes(event.type) && !isNestedSessionEvent(event) && explicitSessionId !== undefined) {
const starts = rootStartsBySource.get(source) ?? [];
starts.push({ index, sessionId: explicitSessionId });
rootStartsBySource.set(source, starts);
}
}
return events.map((event, index) => {
const starts = rootStartsBySource.get(sourceIdentity(event)) ?? [];
const active = [...starts].reverse().find(start => start.index <= index) ?? starts[0];
return active?.sessionId;
});
}

/**
* An agent instance is authoritative; deprecated parent-tool markers are only
* the identity fallback. Child initialization must not change the root session.
* @param {Array<any>} events
*/
function sessionEventContexts(events) {
const sessionIdsByOwner = new Map();
const instancesByAgent = new Map();
const agentsBySpawningTool = new Map();
const rootSessionIds = rootSessionIdsFor(events);
for (const [index, rootSessionId] of rootSessionIds.entries()) {
if (rootSessionId !== undefined) sessionIdsByOwner.set(JSON.stringify([sourceIdentity(events[index]), rootSessionId]), rootSessionId);
}
return events.map((event, index) => {
const data = event.data ?? {};
const source = sourceIdentity(event);
const rootSessionId = rootSessionIds[index];
const rootIdentity = JSON.stringify([source, rootSessionId]);
const explicitSessionId = event.session_id ?? event.sessionId ?? data.sessionId ?? data.session_id;
const { agentId, parentToolUseId, parentToolCallId, parentAgentId, ambiguous, instanceScoped } = resolveAgentIdentity(event, source, rootSessionId, instancesByAgent, agentsBySpawningTool);
const ownerIdentity = agentId !== undefined ? ["agent", agentId, ambiguous ? `unresolved-${index}` : instanceScoped ? parentToolCallId : undefined] : parentToolCallId !== undefined ? ["tool", parentToolCallId] : [];
const sessionIdentity = JSON.stringify([source, rootSessionId, ...ownerIdentity]);
if (["session.start", "session.init"].includes(event.type) && explicitSessionId !== undefined) sessionIdsByOwner.set(sessionIdentity, explicitSessionId);
const sessionId = explicitSessionId ?? sessionIdsByOwner.get(sessionIdentity) ?? rootSessionId;
return {
sourceIdentity: source,
rootSessionId: sessionIdsByOwner.get(rootIdentity),
sessionId,
agentId,
parentToolUseId,
parentAgentId,
parentToolCallId,
nested: agentId !== undefined || parentToolCallId !== undefined,
scope: JSON.stringify([source, sessionId, ...ownerIdentity]),
};
});
}

/** @param {any} usage @returns {Record<string, any>|undefined} */
Expand Down Expand Up @@ -369,7 +505,7 @@ function selectSessionResult(events, { includeNested = false } = {}) {
const claudeSessions = new Map();
for (const event of normalized) {
const context = sessionContext(event);
if (event.type !== "session.result" || event.data.sourceEngine !== "claude" || (!includeNested && context.parentToolUseId) || typeof context.sessionId !== "string") continue;
if (event.type !== "session.result" || event.data.sourceEngine !== "claude" || (!includeNested && isNestedSessionEvent(event)) || typeof context.sessionId !== "string") continue;
const key = sessionScopeKey(event);
claudeSessions.set(key, [...(claudeSessions.get(key) ?? []), event]);
}
Expand All @@ -388,7 +524,7 @@ function selectSessionResult(events, { includeNested = false } = {}) {
}
for (const event of normalized) {
const context = sessionContext(event);
if (event.type !== "session.result" || event.data.sourceEngine !== "claude" || (!includeNested && context.parentToolUseId) || typeof context.sessionId === "string") continue;
if (event.type !== "session.result" || event.data.sourceEngine !== "claude" || (!includeNested && isNestedSessionEvent(event)) || typeof context.sessionId === "string") continue;
// Unassigned diagnostics are valid evidence, but their usage may overlap a named session.
for (const key of ["errors", "permissionDenials"]) {
if (Array.isArray(event.data[key])) aggregate[key] = [...(aggregate[key] ?? []), ...structuredClone(event.data[key])];
Expand All @@ -401,7 +537,7 @@ function selectSessionResult(events, { includeNested = false } = {}) {
let result;
for (const event of normalized) {
if (event.type !== "session.result") continue;
if (!includeNested && event.data.sourceEngine === "claude" && sessionContext(event).parentToolUseId) continue;
if (!includeNested && isNestedSessionEvent(event)) continue;
result ??= {};
const data = event.data;
for (const [key, value] of Object.entries(data)) {
Expand Down Expand Up @@ -438,7 +574,7 @@ function projectSessionInitialization(events, { includeNested = false } = {}) {
let data;
for (const event of normalizeAgentSession(events)) {
if (event.type !== "session.init" && event.type !== "session.start") continue;
if (!includeNested && sessionContext(event).parentToolUseId) continue;
if (!includeNested && isNestedSessionEvent(event)) continue;
data ??= {};
for (const [key, value] of Object.entries(event.data)) {
if (value !== undefined) data = { ...data, [key]: structuredClone(value) };
Expand Down Expand Up @@ -570,7 +706,7 @@ function sessionTokenTotal(usage) {
function observedSessionModel(events, { includeNested = false } = {}) {
let model;
for (const event of events) {
if (!includeNested && sessionContext(event).parentToolUseId) continue;
if (!includeNested && isNestedSessionEvent(event)) continue;
const value = event?.type === "system" && event.subtype === "init" ? event.model : ["session.init", "session.start", "pi.message_snapshot"].includes(event?.type) ? event.data?.model : undefined;
if (typeof value === "string" && value.length) model = value;
}
Expand All @@ -584,6 +720,8 @@ module.exports = {
isSessionEvent,
isTokenCount,
isMetric,
isNestedSessionEvent,
sessionEventContexts,
normalizeSessionUsage,
accumulateSessionUsage,
reconcileSessionUsage,
Expand Down
22 changes: 16 additions & 6 deletions actions/setup/js/agent_session_render.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ const { collectArtifactSecretValues, redactManifestValue } = require("./safe_out
// Redacted identities can coincide; correlate using private source envelopes.
const publicationSources = new WeakMap();

/** @param {any} event @returns {any} */
function sessionPublicationSource(event) {
return publicationSources.get(event) ?? event;
}

/** @param {any} source @returns {string} */
function messageSourceScope(source) {
return JSON.stringify([
Expand All @@ -16,7 +21,7 @@ function messageSourceScope(source) {
source.provenance?.path,
source.session_id ?? source.sessionId ?? source.data?.sessionId ?? source.data?.session_id,
source.agentId ?? source.data?.agentId,
source.parent_tool_use_id ?? source.data?.parentToolUseId ?? source.data?.parent_tool_use_id,
(source.agentId ?? source.data?.agentId) === undefined ? (source.parent_tool_use_id ?? source.data?.parentToolUseId ?? source.data?.parent_tool_use_id ?? source.data?.parentToolCallId) : undefined,
source.channel ?? source.data?.channel,
]);
}
Expand All @@ -37,7 +42,7 @@ function collapseStreamedMessages(events) {
/** @type {{key: string, entry: any, legacy: boolean, field: string}|undefined} */
let previous;
for (const entry of events) {
const source = publicationSources.get(entry) ?? entry;
const source = sessionPublicationSource(entry);
const scope = messageSourceScope(source);
const native = source.event ?? source.data?.event;
if (source.type === "claude.stream_event" && native && ["message_start", "content_block_start", "content_block_delta", "content_block_stop", "message_delta", "message_stop"].includes(native.type)) {
Expand All @@ -48,7 +53,12 @@ function collapseStreamedMessages(events) {
output.push(entry);
continue;
}
if (source.type === "assistant.message_delta" || source.type === "claude.assistant_snapshot" || (source.type === "gemini.message_snapshot" && source.data?.role !== "user" && source.data?.type !== "user")) {
if (
source.type === "assistant.message_delta" ||
source.type === "assistant.reasoning_delta" ||
source.type === "claude.assistant_snapshot" ||
(source.type === "gemini.message_snapshot" && source.data?.role !== "user" && source.data?.type !== "user")
) {
if (source.type === "gemini.message_snapshot") geminiSnapshot = { scope, id: source.data?.messageId ?? source.data?.message_id ?? source.messageId ?? source.message_id, content: entry.data?.content };
output.push(entry);
continue;
Expand All @@ -58,13 +68,13 @@ function collapseStreamedMessages(events) {
const type = legacy ? (block?.type === "text" ? "assistant.message" : block?.type === "thinking" ? "assistant.reasoning" : undefined) : source.type;
const field = block?.type === "thinking" ? "thinking" : "text";
const text = legacy ? block?.[field] : entry.data?.content;
const messageId = source.data?.messageId ?? source.data?.message_id ?? source.messageId ?? source.message_id ?? source.message?.id;
const messageId = (type === "assistant.reasoning" ? source.data?.reasoningId : undefined) ?? source.data?.messageId ?? source.data?.message_id ?? source.messageId ?? source.message_id ?? source.message?.id;
const claude = claudeMessages.get(scope);
const snapshotBlocks = source.message?.content;
const matchingBlocks = Array.isArray(snapshotBlocks) ? snapshotBlocks.map((value, index) => ({ value, index })).filter(({ value }) => value?.type === (type === "assistant.reasoning" ? "thinking" : "text")) : [];
const claudeContinuation = claude?.id !== undefined && messageId === claude.id && matchingBlocks.length === 1;
const position = native?.index ?? source.data?.contentIndex ?? (claudeContinuation ? matchingBlocks[0].index : undefined);
const delta = source.delta === true || source.data?.delta === true || source.copilotProjection === "assistant.message_delta";
const delta = source.delta === true || source.data?.delta === true || ["assistant.message_delta", "assistant.reasoning_delta"].includes(source.copilotProjection);
const partial = source.partial === true || source.data?.partial === true;
const key = JSON.stringify([scope, type, messageId ?? claude?.id, claude?.sequence, position]);
const previousText = previous?.legacy ? previous.entry.message.content[0][previous.field] : previous?.entry.data.content;
Expand Down Expand Up @@ -218,4 +228,4 @@ function redactSessionForPublication(events, redact) {
}
}

module.exports = { collapseStreamedMessages, escapeSummaryText, toolInventoryName, displayArgument, renderInitializationLines, toolOutcome, boundSummaryLines, redactSessionForPublication };
module.exports = { collapseStreamedMessages, escapeSummaryText, toolInventoryName, displayArgument, renderInitializationLines, toolOutcome, boundSummaryLines, redactSessionForPublication, sessionPublicationSource };
7 changes: 5 additions & 2 deletions actions/setup/js/claude_subagent_session.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ describe("Claude subagents and nested sessions", () => {
expect(normalizeClaudeSession(events)).toStrictEqual(events);
expect(normalizeClaudeSession(roundTrip)).toStrictEqual(roundTrip);
const projected = compact(events);
const grouped = scopedAgentSessions(projected).find(group => group.label.includes("parentToolUseId=launch-a"));
const grouped = scopedAgentSessions(projected).find(group => group.label.includes("unresolved subagent") && group.label.includes("parentToolUseId=launch-a"));
expect(grouped.events.find(event => event.type === lifecycle[0].type).agentId).toBe("agent-grandchild");
for (const [kind, session] of [
["agent", events],
Expand Down Expand Up @@ -215,7 +215,10 @@ describe("Claude subagents and nested sessions", () => {
expect(records).toEqual(original);
expect(projectSessionResult(events).usage).toMatchObject({ input_tokens: 14, output_tokens: 6 });
expect(projectSessionResult(compact(events)).usage).toMatchObject({ input_tokens: 14, output_tokens: 6 });
expect(scopedAgentSessions(compact(events))).toHaveLength(7);
const groups = scopedAgentSessions(compact(events));
expect(groups).toHaveLength(8);
expect(groups.filter(group => group.label.includes("sessionId=nested-session"))).toHaveLength(4);
expect(groups.filter(group => group.label.includes("sessionId=second-session"))).toHaveLength(4);
});

it("pairs ID-less results by name only inside the same caller scope", () => {
Expand Down
8 changes: 6 additions & 2 deletions actions/setup/js/copilot_dynamic_workflow.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,11 @@ describe("Copilot dynamic workflow sessions", () => {
it("retains native lifecycle signals and correlates subagents without conflating workflow accounting with session usage", () => {
const original = structuredClone(lifecycle);
const parsed = parseCopilotLog(serialize(lifecycle)).logEntries;
expect(parsed).toEqual(lifecycle);
expect(parsed.map(event => event.type)).toEqual(lifecycle.map(event => event.type));
expect(parsed[5]).toMatchObject({
...lifecycle[5],
data: { ...lifecycle[5].data, agentId: "smoke-child", parentToolCallId: "child-call" },
});
const merged = mergeSessionSources([{ component: "agent", phase: "agent", path: nativePath, events: parsed }]);
expect(merged.map(event => event.type)).toEqual(lifecycle.map(event => event.type));
expect(merged.filter(event => event.type === "workflow.run_updated").map(event => event.data.revision)).toEqual([0, 1]);
Expand Down Expand Up @@ -218,6 +222,6 @@ describe("Copilot dynamic workflow sessions", () => {
expect(events.find(event => event.type === "subagent.started")).toEqual(lifecycle[3]);
expect(events.find(event => event.type === "assistant.message")).toEqual(lifecycle[5]);
expect(events.some(event => event.type === "assistant.message_delta")).toBe(false);
expect(setTimeoutSpy.mock.calls.filter(([, timeout]) => timeout === 1234)).toHaveLength(2);
expect(setTimeoutSpy.mock.calls.filter(([, timeout]) => timeout === 1234)).toHaveLength(0);
});
});
Loading
Loading