Skip to content
Open
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
7 changes: 6 additions & 1 deletion packages/host-daemon-contract/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,9 +335,14 @@
// capacity implied by each model id. The usage event meaning changed across
// the daemon boundary, so enrolled daemons must update with the server.
//
// Version 172 restores Codex native-subagent correlation after a provider
// session resume. A followup to an agent unknown to the fresh bridge now emits
// a nested delegation and parents the resumed child turn instead of projecting
// it as competing root work; send_message remains non-turn-producing.
//
// The version mismatch is what triggers the enrolled daemon's automatic update
// instead of an `invalid-message` reconnect loop.
export const HOST_DAEMON_PROTOCOL_VERSION = 171 as const;
export const HOST_DAEMON_PROTOCOL_VERSION = 172 as const;

/**
* Absolute ceiling for any executable artifact delivered to a host daemon —
Expand Down
2 changes: 1 addition & 1 deletion packages/host-daemon-contract/test/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1046,7 +1046,7 @@ describe("host-daemon command schemas", () => {
// mixed version. Version 113 carried the Devin Desktop open target rename
// and remains part of the protocol lineage.
it("uses the current host-daemon protocol version", () => {
expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(171);
expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(172);
expect(HOST_ARTIFACT_MAX_BYTES).toBe(256 * 1024 * 1024);
});

Expand Down
12 changes: 6 additions & 6 deletions packages/host-daemon-contract/test/payload-size.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,18 +76,18 @@ describe("daemon-to-server event payload sizes", () => {
expect(measurements).toEqual([
{
eventCount: 1,
legacyEnvelope: { gzipBytes: 194, jsonBytes: 413 },
grouped: { gzipBytes: 198, jsonBytes: 421 },
legacyEnvelope: { gzipBytes: 192, jsonBytes: 413 },
grouped: { gzipBytes: 196, jsonBytes: 421 },
},
{
eventCount: 10,
legacyEnvelope: { gzipBytes: 246, jsonBytes: 3_554 },
grouped: { gzipBytes: 247, jsonBytes: 3_049 },
legacyEnvelope: { gzipBytes: 243, jsonBytes: 3_554 },
grouped: { gzipBytes: 243, jsonBytes: 3_049 },
},
{
eventCount: 50,
legacyEnvelope: { gzipBytes: 406, jsonBytes: 17_554 },
grouped: { gzipBytes: 407, jsonBytes: 14_769 },
legacyEnvelope: { gzipBytes: 405, jsonBytes: 17_554 },
grouped: { gzipBytes: 406, jsonBytes: 14_769 },
},
]);

Expand Down
260 changes: 242 additions & 18 deletions plugins/provider-codex/src/translator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,22 @@ describe("codex command output capture across reordering", () => {
describe("codex subagent activity correlation", () => {
const rootProviderThreadId = "root-provider-thread";

function rawCollaborationCall(args: {
callId: string;
name: "followup_task" | "send_message";
}) {
return codexEvent("rawResponseItem/completed", {
threadId: rootProviderThreadId,
turnId: "parent-turn",
item: {
type: "function_call",
name: args.name,
arguments: '{"target":"/root/lifecycle_child"}',
call_id: args.callId,
},
});
}

function subAgentActivity(args: {
agentThreadId?: string;
id: string;
Expand Down Expand Up @@ -900,13 +916,15 @@ describe("codex subagent activity correlation", () => {
);
harness.translate(childTurnCompleted("child-turn-1"));

// A follow-up to a settled agent re-opens its delegation row (same item
// id): the agent works again, and an open delegation is open work.
// `interacted` alone is ambiguous: Codex uses it for both followup_task
// and send_message. Wait for the child turn before reopening the row.
expect(
harness.translate(
subAgentActivity({ id: "interaction-1", kind: "interacted" }),
),
).toEqual([
).toEqual([]);

expect(harness.translate(childTurnStarted("child-turn-2"))).toEqual([
expect.objectContaining({
type: "item/started",
scope: turnScope(harness.turnId("parent-turn")),
Expand All @@ -916,15 +934,12 @@ describe("codex subagent activity correlation", () => {
status: "pending",
}),
}),
]);

expect(harness.translate(childTurnStarted("child-turn-2"))).toContainEqual(
expect.objectContaining({
type: "turn/started",
scope: turnScope(harness.turnId("child-turn-2")),
parentToolCallId: harness.itemId("subagent-call-1"),
}),
);
]);

// The resumed turn settles the re-opened delegation again.
const resumedTurnCompleted = harness.translate(
Expand All @@ -947,9 +962,213 @@ describe("codex subagent activity correlation", () => {
]);
});

// Follow-ups queue: two interactions owe two more child turns. The re-arm is
// counted, so terminalizing the agent after the first follow-up must not
// discard the link the second one still needs.
// When app-server supplies the raw collaboration call, it distinguishes a
// turn-producing followup from a message that must not reserve the next
// native turn. Resumed sessions can omit this notification; the rawless
// cases below cover that event shape.
it("links an unknown resumed subagent from the raw followup intent after translator restart", () => {
const harness = createHarness();

expect(
harness.translate(
rawCollaborationCall({
callId: "message-call",
name: "send_message",
}),
),
).toEqual([]);
expect(
harness.translate(
subAgentActivity({ id: "message-call", kind: "interacted" }),
),
).toEqual([]);

expect(
harness.translate(
rawCollaborationCall({
callId: "followup-call",
name: "followup_task",
}),
),
).toEqual([]);
expect(
harness.translate(
subAgentActivity({ id: "followup-call", kind: "interacted" }),
),
).toEqual([
expect.objectContaining({
type: "item/started",
scope: turnScope(harness.turnId("parent-turn")),
item: expect.objectContaining({
type: "delegation",
id: harness.itemId("followup-call"),
childRef: "agent-thread-1",
status: "pending",
}),
}),
]);

expect(
harness.translate(childTurnStarted("resumed-child-turn")),
).toContainEqual(
expect.objectContaining({
type: "turn/started",
scope: turnScope(harness.turnId("resumed-child-turn")),
parentToolCallId: harness.itemId("followup-call"),
}),
);
});

it("does not reopen a known terminal subagent for send_message", () => {
const harness = createHarness();
harness.translate(
subAgentActivity({ id: "subagent-call-1", kind: "started" }),
);
harness.translate(childTurnStarted("child-turn-1"));
harness.translate(childTurnCompleted("child-turn-1"));

harness.translate(
rawCollaborationCall({ callId: "message-call", name: "send_message" }),
);
expect(
harness.translate(
subAgentActivity({ id: "message-call", kind: "interacted" }),
),
).toEqual([]);

expect(
harness.translator.prepareTurnStart({
clientRequestId: "creq_after_message",
providerThreadId: rootProviderThreadId,
}),
).not.toBeNull();
const nextRootTurn = harness
.translate(childTurnStarted("next-root-turn"))
.find((event) => event.type === "turn/started");
expect(nextRootTurn).not.toHaveProperty("parentToolCallId");
});

it("links a rawless resumed subagent when its child turn starts", () => {
const harness = createHarness();

expect(
harness.translate(
subAgentActivity({ id: "rawless-followup", kind: "interacted" }),
),
).toEqual([]);

const resumedEvents = harness.translate(
childTurnStarted("rawless-child-turn"),
);
expect(resumedEvents).toEqual([
expect.objectContaining({
type: "item/started",
scope: turnScope(harness.turnId("parent-turn")),
item: expect.objectContaining({
type: "delegation",
id: harness.itemId("rawless-followup"),
childRef: "agent-thread-1",
}),
}),
expect.objectContaining({
type: "turn/started",
scope: turnScope(harness.turnId("rawless-child-turn")),
parentToolCallId: harness.itemId("rawless-followup"),
}),
]);
expect(resumedEvents[0]).not.toHaveProperty("parentToolCallId");
expect(resumedEvents[0]).not.toHaveProperty("item.parentToolCallId");
});

// Production resumes report the interaction on the root thread and the
// resulting child turn on the agent's own provider thread. Once the parent
// settles, a new root prompt can start while that child is still running;
// the two provider-thread-scoped correlations must remain independent.
it("keeps root input correlation independent from a rawless resumed child thread", () => {
const harness = createHarness();

expect(
harness.translate(
subAgentActivity({ id: "rawless-followup", kind: "interacted" }),
),
).toEqual([]);

expect(
harness.translate(
childTurnStarted("rawless-child-turn", "agent-thread-1"),
),
).toEqual([
expect.objectContaining({
type: "item/started",
scope: turnScope(harness.turnId("parent-turn")),
item: expect.objectContaining({
type: "delegation",
id: harness.itemId("rawless-followup"),
childRef: "agent-thread-1",
}),
}),
expect.objectContaining({
type: "turn/started",
scope: turnScope(harness.turnId("rawless-child-turn")),
parentToolCallId: harness.itemId("rawless-followup"),
}),
]);

harness.translate(childTurnCompleted("parent-turn"));
expect(
harness.translator.prepareTurnStart({
clientRequestId: "creq_while_child_running",
providerThreadId: rootProviderThreadId,
}),
).not.toBeNull();

const rootEvents = harness.translate(childTurnStarted("next-root-turn"));
expect(rootEvents).toContainEqual(
expect.objectContaining({
type: "turn/started",
scope: turnScope(harness.turnId("next-root-turn")),
}),
);
expect(rootEvents).toContainEqual(
expect.objectContaining({
type: "turn/input/accepted",
scope: turnScope(harness.turnId("next-root-turn")),
clientRequestId: "creq_while_child_running",
}),
);
expect(
rootEvents.find((event) => event.type === "turn/started"),
).not.toHaveProperty("parentToolCallId");

expect(
harness
.translate(childTurnCompleted("rawless-child-turn", "agent-thread-1"))
.map((event) => event.type),
).toEqual(["turn/completed", "item/completed"]);
});

it("discards a rawless message interaction at its parent boundary", () => {
const harness = createHarness();
expect(
harness.translate(
subAgentActivity({ id: "rawless-message", kind: "interacted" }),
),
).toEqual([]);
harness.translate(childTurnCompleted("parent-turn"));

harness.translator.prepareTurnStart({
clientRequestId: "creq_after_rawless_message",
providerThreadId: rootProviderThreadId,
});
const nextRootTurn = harness
.translate(childTurnStarted("next-root-after-message"))
.find((event) => event.type === "turn/started");
expect(nextRootTurn).not.toHaveProperty("parentToolCallId");
});

// Each child turn consumes one ambiguous interaction. Settling the first
// resumed turn must not discard the second interaction that still awaits
// its own turn-producing proof.
it("preserves the parent link across queued follow-up resumes", () => {
const harness = createHarness();
harness.translate(
Expand All @@ -958,14 +1177,15 @@ describe("codex subagent activity correlation", () => {
harness.translate(childTurnStarted("child-turn-1"));
harness.translate(childTurnCompleted("child-turn-1"));

// The first follow-up re-opens the delegation; the second finds it open.
// Neither ambiguous interaction re-opens the delegation until a child
// turn proves that it was a turn-producing follow-up.
expect(
harness
.translate(
subAgentActivity({ id: "interaction-1", kind: "interacted" }),
)
.map((event) => event.type),
).toEqual(["item/started"]);
).toEqual([]);
expect(
harness.translate(
subAgentActivity({ id: "interaction-2", kind: "interacted" }),
Expand All @@ -975,21 +1195,25 @@ describe("codex subagent activity correlation", () => {
for (const index of [2, 3]) {
expect(
harness.translate(childTurnStarted(`child-turn-${index}`)),
).toContainEqual(
).toEqual([
expect.objectContaining({
type: "item/started",
item: expect.objectContaining({
type: "delegation",
id: harness.itemId("subagent-call-1"),
}),
}),
expect.objectContaining({
type: "turn/started",
scope: turnScope(harness.turnId(`child-turn-${index}`)),
parentToolCallId: harness.itemId("subagent-call-1"),
}),
);
// The delegation closes only once the last owed follow-up turn settles.
]);
expect(
harness
.translate(childTurnCompleted(`child-turn-${index}`))
.map((event) => event.type),
).toEqual(
index === 3 ? ["turn/completed", "item/completed"] : ["turn/completed"],
);
).toEqual(["turn/completed", "item/completed"]);
}
});

Expand Down
Loading