fix(cli): surface gateway→embedded fallback session divergence in --json output (#111645)

This commit is contained in:
Peter Steinberger
2026-07-19 21:26:58 -07:00
committed by GitHub
parent d18a7cff8e
commit 1ee42427ea
3 changed files with 53 additions and 12 deletions
+5
View File
@@ -35,6 +35,11 @@ export type AgentCommandResultMetaOverrides = {
fallbackReason?: "gateway_timeout" | "gateway_closed";
fallbackSessionId?: string;
fallbackSessionKey?: string;
fallback?: {
reason: "gateway_timeout" | "gateway_closed";
requestedSessionKey: string | null;
sessionKey: string;
};
};
/** ACP turn source markers accepted by trusted command callsites. */
+41 -11
View File
@@ -1737,7 +1737,7 @@ describe("agentCliCommand", () => {
expect(
mockMessages(runtime.error).some((message) =>
message.includes(
"Gateway agent connection closed; running embedded agent with fresh session",
"Gateway agent connection closed; continuity was intentionally not preserved",
),
),
).toBe(true);
@@ -1900,7 +1900,7 @@ describe("agentCliCommand", () => {
expect(resultMetaOverrides.fallbackSessionKey).toBe(fallbackSessionKey);
expect(
mockMessages(runtime.error).some((message) =>
message.includes("Gateway agent timed out; running embedded agent with fresh session"),
message.includes("Gateway agent timed out; continuity was intentionally not preserved"),
),
).toBe(true);
expect(runtime.log).toHaveBeenCalledWith("local");
@@ -1990,16 +1990,25 @@ describe("agentCliCommand", () => {
});
});
it("passes fallback metadata into JSON embedded fallback output", async () => {
it.each([
{
label: "connection-closed",
createError: createGatewayClosedError,
reason: "gateway_closed" as const,
},
{
label: "timeout",
createError: createGatewayTimeoutError,
reason: "gateway_timeout" as const,
},
])("surfaces $label session divergence in JSON fallback output", async (testCase) => {
await withTempStore(async () => {
callGateway.mockRejectedValue(createGatewayClosedError());
const requestedSessionKey = "agent:main:incident-42";
callGateway.mockRejectedValue(testCase.createError());
agentCommand.mockImplementationOnce(async (opts, rt) => {
expect(loggingState.forceConsoleToStderr).toBe(true);
const resultMetaOverrides = (
opts as {
resultMetaOverrides?: { transport?: string; fallbackFrom?: string };
}
).resultMetaOverrides;
const resultMetaOverrides = (opts as { resultMetaOverrides?: Record<string, unknown> })
.resultMetaOverrides;
const meta = {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
@@ -2021,7 +2030,10 @@ describe("agentCliCommand", () => {
} as unknown as Awaited<ReturnType<typeof AgentCommand>>;
});
const result = await agentCliCommand({ message: "hi", to: "+1555", json: true }, jsonRuntime);
const result = await agentCliCommand(
{ message: "hi", sessionKey: requestedSessionKey, json: true },
jsonRuntime,
);
expect(agentCommand).toHaveBeenCalledTimes(1);
const fallbackOpts = requireRecord(
@@ -2032,11 +2044,19 @@ describe("agentCliCommand", () => {
fallbackOpts.resultMetaOverrides,
"fallback metadata",
);
const fallbackSessionKey = String(fallbackOpts.sessionKey);
expect(resultMetaOverrides.transport).toBe("embedded");
expect(resultMetaOverrides.fallbackFrom).toBe("gateway");
expect(resultMetaOverrides.fallback).toEqual({
reason: testCase.reason,
requestedSessionKey,
sessionKey: fallbackSessionKey,
});
expect(
mockMessages(jsonRuntime.error).some((message) =>
message.includes("EMBEDDED FALLBACK: Gateway agent connection closed"),
message.includes(
"continuity was intentionally not preserved. Running embedded agent with fresh session",
),
),
).toBe(true);
expect(loggingState.forceConsoleToStderr).toBe(true);
@@ -2048,11 +2068,21 @@ describe("agentCliCommand", () => {
expect(payloadMeta.durationMs).toBe(1);
expect(payloadMeta.transport).toBe("embedded");
expect(payloadMeta.fallbackFrom).toBe("gateway");
expect(payloadMeta.fallback).toEqual({
reason: testCase.reason,
requestedSessionKey,
sessionKey: fallbackSessionKey,
});
const resultRecord = requireRecord(result, "command result");
const resultMeta = requireRecord(resultRecord.meta, "command result metadata");
expect(resultMeta.durationMs).toBe(1);
expect(resultMeta.transport).toBe("embedded");
expect(resultMeta.fallbackFrom).toBe("gateway");
expect(resultMeta.fallback).toEqual({
reason: testCase.reason,
requestedSessionKey,
sessionKey: fallbackSessionKey,
});
});
});
+7 -1
View File
@@ -960,6 +960,7 @@ export async function agentCliCommand(
runtime.exit(1);
return undefined;
}
const requestedSessionKey = messageOpts.sessionKey?.trim() || null;
const dispatchOpts = await normalizeSessionKeyOptsForDispatch(messageOpts);
validateExplicitSessionKeyForDispatch(dispatchOpts);
const gatewayDispatchOpts = dispatchOpts.runId
@@ -1006,7 +1007,7 @@ export async function agentCliCommand(
// Transport loss is ambiguous: the Gateway may still own or recover the original turn.
// Keep embedded work on a separate session so both processes cannot write one transcript.
runtime.error?.(
`EMBEDDED FALLBACK: Gateway agent ${fallbackReason === "gateway_timeout" ? "timed out" : "connection closed"}; running embedded agent with fresh session ${fallbackSession.sessionId}: ${String(err)}`,
`EMBEDDED FALLBACK: Gateway agent ${fallbackReason === "gateway_timeout" ? "timed out" : "connection closed"}; continuity was intentionally not preserved. Running embedded agent with fresh session ${fallbackSession.sessionId} to avoid double-driving the original session: ${String(err)}`,
);
const agentCommand = await loadEmbeddedAgentCommand();
const result = await agentCommand(
@@ -1020,6 +1021,11 @@ export async function agentCliCommand(
fallbackReason,
fallbackSessionId: fallbackSession.sessionId,
fallbackSessionKey: fallbackSession.sessionKey,
fallback: {
reason: fallbackReason,
requestedSessionKey,
sessionKey: fallbackSession.sessionKey,
},
},
},
runtime,