feat(ui): show custom commit age in sidebar footer (#120388)

* feat(ui): show custom commit age in sidebar footer

* test: repair sidebar build status CI gates

* test(ui): assert gateway state during reconnects

* fix(ui): expose build provenance in sidebar label

* fix(ci): align SDK baseline and test formatting

* fix(sessions): preserve context usage provenance

* test(agents): split session loop lifecycle coverage

* test(agents): reuse session loop resource helpers

* test(sessions): preserve stale totals in JSON output
This commit is contained in:
Peter Steinberger
2026-08-08 03:03:50 -07:00
committed by GitHub
parent 10c7f005be
commit c933145088
18 changed files with 1069 additions and 838 deletions
@@ -5,30 +5,17 @@ import { setDiagnosticsEnabledForProcess } from "../../infra/diagnostic-events.j
import { resetDiagnosticRunActivityForTest } from "../../logging/diagnostic-run-activity.js";
import { markDiagnosticToolStartedForTest } from "../../logging/diagnostic-run-activity.test-support.js";
import { resetDiagnosticSessionStateForTest } from "../../logging/diagnostic-session-state.js";
import { queueEmbeddedAgentMessageWithOutcome, setActiveEmbeddedRun } from "./runs.js";
import { testing } from "./runs.test-support.js";
import { createUserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.js";
import { createTestUserTurnTranscriptTarget } from "../../sessions/user-turn-transcript.test-support.js";
import {
formatEmbeddedAgentQueueFailureSummary,
queueEmbeddedAgentMessageWithOutcome,
queueEmbeddedAgentMessageWithOutcomeAsync,
setActiveEmbeddedRun,
} from "./runs.js";
import { createEmbeddedRunHandle, testing } from "./runs.test-support.js";
type RunHandle = Parameters<typeof setActiveEmbeddedRun>[1];
function createSteeringRunHandle(
overrides: {
isStreaming?: boolean;
isStopped?: () => boolean;
queueMessage?: RunHandle["queueMessage"];
supportsQueueMessageImages?: boolean;
} = {},
): RunHandle {
return {
queueMessage: overrides.queueMessage ?? (async () => {}),
isStreaming: () => overrides.isStreaming ?? true,
...(overrides.isStopped ? { isStopped: overrides.isStopped } : {}),
isCompacting: () => false,
supportsQueueMessageImages: overrides.supportsQueueMessageImages,
abort: () => {},
};
}
describe("embedded-agent runner steering admission", () => {
describe("embedded-agent active-run steering", () => {
afterEach(() => {
testing.resetActiveEmbeddedRuns();
replyRunTesting.resetReplyRunRegistry();
@@ -40,7 +27,7 @@ describe("embedded-agent runner steering admission", () => {
it("passes steering options to active embedded runs", () => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun("session-steer", {
...createSteeringRunHandle(),
...createEmbeddedRunHandle(),
sourceReplyDeliveryMode: "message_tool_only",
queueMessage,
});
@@ -61,7 +48,7 @@ describe("embedded-agent runner steering admission", () => {
it("rejects images when the active run cannot preserve them", () => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun("session-images", {
...createSteeringRunHandle(),
...createEmbeddedRunHandle(),
queueMessage,
});
@@ -79,7 +66,7 @@ describe("embedded-agent runner steering admission", () => {
setActiveEmbeddedRun(
"session-images",
createSteeringRunHandle({ queueMessage, supportsQueueMessageImages: true }),
createEmbeddedRunHandle({ queueMessage, supportsQueueMessageImages: true }),
);
expect(
@@ -95,7 +82,7 @@ describe("embedded-agent runner steering admission", () => {
it("rejects message-tool-only steering for active runs created without that mode", () => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun("session-automatic-source-reply", {
...createSteeringRunHandle(),
...createEmbeddedRunHandle(),
queueMessage,
});
@@ -131,7 +118,7 @@ describe("embedded-agent runner steering admission", () => {
])("rejects $label", ({ handleMode, requestMode }) => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun("session-task-suggestions", {
...createSteeringRunHandle(),
...createEmbeddedRunHandle(),
taskSuggestionDeliveryMode: handleMode,
queueMessage,
});
@@ -153,7 +140,7 @@ describe("embedded-agent runner steering admission", () => {
it("defaults active embedded steering to all pending messages", () => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun("session-default-steer", {
...createSteeringRunHandle(),
...createEmbeddedRunHandle(),
queueMessage,
});
@@ -168,7 +155,7 @@ describe("embedded-agent runner steering admission", () => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun(
"session-active-non-streaming",
createSteeringRunHandle({
createEmbeddedRunHandle({
isStreaming: false,
isStopped: () => false,
queueMessage,
@@ -185,7 +172,7 @@ describe("embedded-agent runner steering admission", () => {
vi.useFakeTimers();
try {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun("session-stale-steer", createSteeringRunHandle({ queueMessage }));
setActiveEmbeddedRun("session-stale-steer", createEmbeddedRunHandle({ queueMessage }));
vi.advanceTimersByTime(10 * 60_000 + 1);
@@ -207,7 +194,7 @@ describe("embedded-agent runner steering admission", () => {
vi.useFakeTimers();
try {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun("session-quiet-tool-steer", createSteeringRunHandle({ queueMessage }));
setActiveEmbeddedRun("session-quiet-tool-steer", createEmbeddedRunHandle({ queueMessage }));
markDiagnosticToolStartedForTest({
sessionId: "session-quiet-tool-steer",
toolName: "exec",
@@ -261,7 +248,7 @@ describe("embedded-agent runner steering admission", () => {
const freshQueueMessage = vi.fn(async () => {});
setActiveEmbeddedRun(
"session-fresh-steer",
createSteeringRunHandle({ queueMessage: freshQueueMessage }),
createEmbeddedRunHandle({ queueMessage: freshQueueMessage }),
);
expect(queueEmbeddedAgentMessageWithOutcome("session-fresh-steer", "continue").queued).toBe(
@@ -272,7 +259,7 @@ describe("embedded-agent runner steering admission", () => {
const missingSnapshotQueueMessage = vi.fn(async () => {});
setActiveEmbeddedRun(
"session-no-diagnostic-snapshot",
createSteeringRunHandle({ queueMessage: missingSnapshotQueueMessage }),
createEmbeddedRunHandle({ queueMessage: missingSnapshotQueueMessage }),
);
resetDiagnosticRunActivityForTest();
@@ -286,7 +273,7 @@ describe("embedded-agent runner steering admission", () => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun(
"session-stopped",
createSteeringRunHandle({
createEmbeddedRunHandle({
isStreaming: true,
isStopped: () => true,
queueMessage,
@@ -308,7 +295,7 @@ describe("embedded-agent runner steering admission", () => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun(
"session-bad-state",
createSteeringRunHandle({
createEmbeddedRunHandle({
isStopped: () => {
throw new Error("bad stopped state");
},
@@ -326,4 +313,151 @@ describe("embedded-agent runner steering admission", () => {
});
expect(queueMessage).not.toHaveBeenCalled();
});
it("returns a structured no-active-run queue failure", () => {
const outcome = queueEmbeddedAgentMessageWithOutcome("session-missing", "continue");
expect(outcome).toEqual({
queued: false,
sessionId: "session-missing",
reason: "no_active_run",
gatewayHealth: "live",
});
expect(formatEmbeddedAgentQueueFailureSummary(outcome)).toBe(
"queue_message_failed reason=no_active_run sessionId=session-missing gatewayHealth=live",
);
});
it("returns structured queue failures for legacy, unavailable, or compacting runs", () => {
const legacyQueue = vi.fn(async () => {});
const unavailableQueue = vi.fn(async () => {});
setActiveEmbeddedRun(
"session-not-streaming",
createEmbeddedRunHandle({ isStreaming: false, queueMessage: legacyQueue }),
);
setActiveEmbeddedRun(
"session-unavailable",
createEmbeddedRunHandle({
messageInjection: { isAvailable: () => false, queueMessage: unavailableQueue },
}),
);
setActiveEmbeddedRun("session-compacting", createEmbeddedRunHandle({ isCompacting: true }));
expect(queueEmbeddedAgentMessageWithOutcome("session-not-streaming", "continue")).toMatchObject(
{ queued: false, reason: "not_streaming" },
);
expect(legacyQueue).not.toHaveBeenCalled();
expect(queueEmbeddedAgentMessageWithOutcome("session-unavailable", "continue")).toMatchObject({
queued: false,
reason: "not_streaming",
});
expect(unavailableQueue).not.toHaveBeenCalled();
expect(queueEmbeddedAgentMessageWithOutcome("session-compacting", "continue")).toMatchObject({
queued: false,
reason: "compacting",
});
});
it("returns runtime rejection details when async queue delivery fails", async () => {
setActiveEmbeddedRun("session-rejected", {
...createEmbeddedRunHandle(),
queueMessage: async () => {
throw new Error("cannot steer a compact turn");
},
});
const outcome = await queueEmbeddedAgentMessageWithOutcomeAsync("session-rejected", "continue");
expect(outcome).toEqual({
queued: false,
sessionId: "session-rejected",
reason: "runtime_rejected",
gatewayHealth: "live",
errorMessage: "cannot steer a compact turn",
});
expect(formatEmbeddedAgentQueueFailureSummary(outcome)).toBe(
"queue_message_failed reason=runtime_rejected sessionId=session-rejected gatewayHealth=live error=cannot steer a compact turn",
);
});
it("reports accepted steering without transcript confirmation as non-replayable", async () => {
setActiveEmbeddedRun("session-unconfirmed", {
...createEmbeddedRunHandle(),
queueMessage: async () => ({
transcriptCommit: "unconfirmed",
errorMessage: "receipt unavailable",
}),
});
const outcome = await queueEmbeddedAgentMessageWithOutcomeAsync(
"session-unconfirmed",
"continue",
);
expect(outcome).toEqual({
queued: true,
sessionId: "session-unconfirmed",
target: "embedded_run",
gatewayHealth: "live",
transcriptCommit: "unconfirmed",
errorMessage: "receipt unavailable",
enqueuedAtMs: expect.any(Number),
});
});
it("rejects transcript-commit waits for active handles without support", async () => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun("session-no-transcript-wait", {
...createEmbeddedRunHandle(),
queueMessage,
});
const outcome = await queueEmbeddedAgentMessageWithOutcomeAsync(
"session-no-transcript-wait",
"continue",
{ waitForTranscriptCommit: true },
);
expect(outcome).toEqual({
queued: false,
sessionId: "session-no-transcript-wait",
reason: "transcript_commit_wait_unsupported",
gatewayHealth: "live",
});
expect(queueMessage).not.toHaveBeenCalled();
});
it("rejects transcript-commit waits before reply-run fallback without an active handle", async () => {
const queueMessage = vi.fn(async () => {});
const operation = createReplyOperation({
sessionKey: "agent:main:main",
sessionId: "session-reply-run",
resetTriggered: false,
});
operation.attachBackend({
kind: "embedded",
cancel: vi.fn(),
isStreaming: () => true,
queueMessage,
});
operation.setPhase("running");
const recorder = createUserTurnTranscriptRecorder({
input: { text: "visible group prompt", sender: { id: "user-42" } },
target: createTestUserTurnTranscriptTarget(),
});
const outcome = await queueEmbeddedAgentMessageWithOutcomeAsync(
"session-reply-run",
"completion from child",
{ waitForTranscriptCommit: true, userTurnTranscriptRecorder: recorder },
);
expect(outcome).toEqual({
queued: false,
sessionId: "session-reply-run",
reason: "transcript_commit_wait_unsupported",
gatewayHealth: "live",
});
expect(queueMessage).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,40 @@
import "./runs.js";
import type { EmbeddedAgentQueueHandle } from "./run-state.js";
type RunHandle = EmbeddedAgentQueueHandle;
export function createEmbeddedRunHandle(
overrides: {
abort?: () => void;
isAbortable?: boolean;
isCompacting?: boolean;
isStreaming?: boolean;
isStopped?: () => boolean;
messageInjection?: RunHandle["messageInjection"];
runId?: string;
queueMessage?: RunHandle["queueMessage"];
supportsQueueMessageImages?: boolean;
supportsTranscriptCommitWait?: boolean;
} = {},
): RunHandle {
// Minimal handle fixture with overrideable lifecycle probes for registry
// behavior; individual tests supply queue/abort behavior when needed.
const abort = overrides.abort ?? (() => {});
return {
runId: overrides.runId,
queueMessage: overrides.queueMessage ?? (async () => {}),
...(overrides.messageInjection ? { messageInjection: overrides.messageInjection } : {}),
isStreaming: () => overrides.isStreaming ?? true,
...(overrides.isStopped ? { isStopped: overrides.isStopped } : {}),
...(overrides.isAbortable !== undefined
? { isAbortable: () => overrides.isAbortable !== false }
: {}),
isCompacting: () => overrides.isCompacting ?? false,
supportsQueueMessageImages: overrides.supportsQueueMessageImages,
supportsTranscriptCommitWait: overrides.supportsTranscriptCommitWait,
abort,
};
}
type EmbeddedRunsTestApi = {
persistForceClearedEmbeddedRunTerminalState(params: {
+19 -203
View File
@@ -11,8 +11,6 @@ import {
getDiagnosticSessionState,
resetDiagnosticSessionStateForTest,
} from "../../logging/diagnostic-session-state.js";
import { createUserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.js";
import { createTestUserTurnTranscriptTarget } from "../../sessions/user-turn-transcript.test-support.js";
import {
abortAndDrainEmbeddedAgentRun,
abortEmbeddedAgentRun,
@@ -21,48 +19,10 @@ import {
isEmbeddedAgentRunAbortableForRunId,
isEmbeddedAgentRunAbortableForCompaction,
isEmbeddedAgentRunHandleActive,
formatEmbeddedAgentQueueFailureSummary,
queueEmbeddedAgentMessageWithOutcome,
queueEmbeddedAgentMessageWithOutcomeAsync,
retainEmbeddedAgentRunAbortabilityForRunId,
setActiveEmbeddedRun,
} from "./runs.js";
import { testing } from "./runs.test-support.js";
type RunHandle = Parameters<typeof setActiveEmbeddedRun>[1];
function createRunHandle(
overrides: {
abort?: () => void;
isAbortable?: boolean;
isCompacting?: boolean;
isStreaming?: boolean;
isStopped?: () => boolean;
messageInjection?: RunHandle["messageInjection"];
runId?: string;
queueMessage?: RunHandle["queueMessage"];
supportsQueueMessageImages?: boolean;
supportsTranscriptCommitWait?: boolean;
} = {},
): RunHandle {
// Minimal handle fixture with overrideable lifecycle probes for registry
// behavior; individual tests supply queue/abort behavior when needed.
const abort = overrides.abort ?? (() => {});
return {
runId: overrides.runId,
queueMessage: overrides.queueMessage ?? (async () => {}),
...(overrides.messageInjection ? { messageInjection: overrides.messageInjection } : {}),
isStreaming: () => overrides.isStreaming ?? true,
...(overrides.isStopped ? { isStopped: overrides.isStopped } : {}),
...(overrides.isAbortable !== undefined
? { isAbortable: () => overrides.isAbortable !== false }
: {}),
isCompacting: () => overrides.isCompacting ?? false,
supportsQueueMessageImages: overrides.supportsQueueMessageImages,
supportsTranscriptCommitWait: overrides.supportsTranscriptCommitWait,
abort,
};
}
import { createEmbeddedRunHandle, testing } from "./runs.test-support.js";
describe("embedded-agent runner run registry", () => {
afterEach(() => {
@@ -81,10 +41,10 @@ describe("embedded-agent runner run registry", () => {
setActiveEmbeddedRun(
"session-compacting",
createRunHandle({ isCompacting: true, abort: abortCompacting }),
createEmbeddedRunHandle({ isCompacting: true, abort: abortCompacting }),
);
setActiveEmbeddedRun("session-normal", createRunHandle({ abort: abortNormal }));
setActiveEmbeddedRun("session-normal", createEmbeddedRunHandle({ abort: abortNormal }));
const aborted = abortEmbeddedAgentRun(undefined, { mode: "compacting" });
expect(aborted).toBe(true);
@@ -110,9 +70,12 @@ describe("embedded-agent runner run registry", () => {
const abortA = vi.fn();
const abortB = vi.fn();
setActiveEmbeddedRun("session-a", createRunHandle({ isCompacting: true, abort: abortA }));
setActiveEmbeddedRun(
"session-a",
createEmbeddedRunHandle({ isCompacting: true, abort: abortA }),
);
setActiveEmbeddedRun("session-b", createRunHandle({ abort: abortB }));
setActiveEmbeddedRun("session-b", createEmbeddedRunHandle({ abort: abortB }));
const aborted = abortEmbeddedAgentRun(undefined, { mode: "all" });
expect(aborted).toBe(true);
@@ -122,7 +85,7 @@ describe("embedded-agent runner run registry", () => {
it("keeps finalizing runs active while rejecting abort requests", () => {
const abort = vi.fn();
const handle = createRunHandle({ abort, isAbortable: false });
const handle = createEmbeddedRunHandle({ abort, isAbortable: false });
const operation = createReplyOperation({
sessionKey: "agent:main:finalizing",
sessionId: "session-finalizing",
@@ -155,7 +118,7 @@ describe("embedded-agent runner run registry", () => {
it("keeps frozen run ownership through forced in-process restart", () => {
const abort = vi.fn();
const handle = createRunHandle({ abort, isAbortable: false });
const handle = createEmbeddedRunHandle({ abort, isAbortable: false });
const operation = createReplyOperation({
sessionKey: "agent:main:restart-finalizing",
sessionId: "session-restart-finalizing",
@@ -185,7 +148,7 @@ describe("embedded-agent runner run registry", () => {
});
it("binds abortability to the owning run id", () => {
const finalizing = createRunHandle({
const finalizing = createEmbeddedRunHandle({
abort: vi.fn(),
isAbortable: false,
runId: "run-finalizing",
@@ -203,7 +166,7 @@ describe("embedded-agent runner run registry", () => {
clearActiveEmbeddedRun("session-shared", finalizing);
expect(isEmbeddedAgentRunAbortableForRunId("run-finalizing")).toBe(false);
const queued = createRunHandle({ runId: "run-queued" });
const queued = createEmbeddedRunHandle({ runId: "run-queued" });
setActiveEmbeddedRun("session-shared", queued);
expect(isEmbeddedAgentRunAbortableForRunId("run-finalizing")).toBe(false);
@@ -215,7 +178,7 @@ describe("embedded-agent runner run registry", () => {
it("passes restart ownership to every aborted run", () => {
const abort = vi.fn();
setActiveEmbeddedRun("session-restart", createRunHandle({ abort }));
setActiveEmbeddedRun("session-restart", createEmbeddedRunHandle({ abort }));
expect(abortEmbeddedAgentRun(undefined, { mode: "all", reason: "restart" })).toBe(true);
expect(abort).toHaveBeenCalledWith("restart");
@@ -257,7 +220,7 @@ describe("embedded-agent runner run registry", () => {
sessionId: "session-reply-stuck-live",
resetTriggered: false,
});
const handle = createRunHandle({
const handle = createEmbeddedRunHandle({
abort: () => {
operation.abortByUser();
},
@@ -284,7 +247,7 @@ describe("embedded-agent runner run registry", () => {
it("claims shared restart ownership before invoking an attached handle", () => {
const abort = vi.fn();
const handle = createRunHandle({ abort });
const handle = createEmbeddedRunHandle({ abort });
const operation = createReplyOperation({
sessionKey: "agent:main:restart-owned",
sessionId: "session-restart-owned",
@@ -309,7 +272,7 @@ describe("embedded-agent runner run registry", () => {
"does not bypass frozen shared ownership through %s handle aborts",
(mode) => {
const abort = vi.fn();
const handle = createRunHandle({ abort, isCompacting: true });
const handle = createEmbeddedRunHandle({ abort, isCompacting: true });
const sessionId = `session-restart-frozen-${mode}`;
const operation = createReplyOperation({
sessionKey: `agent:main:restart-frozen-${mode}`,
@@ -337,7 +300,7 @@ describe("embedded-agent runner run registry", () => {
const abort = vi.fn(() => {
throw new Error("cancel failed");
});
const handle = createRunHandle({ abort });
const handle = createEmbeddedRunHandle({ abort });
const operation = createReplyOperation({
sessionKey: "agent:main:restart-throwing",
sessionId: "session-restart-throwing",
@@ -359,7 +322,7 @@ describe("embedded-agent runner run registry", () => {
it("does not bypass retained terminal ownership through compacting handle aborts", () => {
const abort = vi.fn();
const handle = createRunHandle({ abort, isCompacting: true });
const handle = createEmbeddedRunHandle({ abort, isCompacting: true });
const operation = createReplyOperation({
sessionKey: "agent:main:restart-failed-compacting",
sessionId: "session-restart-failed-compacting",
@@ -385,7 +348,7 @@ describe("embedded-agent runner run registry", () => {
it("records active run session files in diagnostic state for heartbeat recovery", () => {
setDiagnosticsEnabledForProcess(true);
const sessionFile = "/tmp/openclaw-run-registry-session.jsonl";
const handle = createRunHandle();
const handle = createEmbeddedRunHandle();
setActiveEmbeddedRun("session-file-diagnostics", handle, "agent:main:visible", sessionFile);
@@ -393,151 +356,4 @@ describe("embedded-agent runner run registry", () => {
sessionFile,
);
});
it("returns a structured no-active-run queue failure", () => {
const outcome = queueEmbeddedAgentMessageWithOutcome("session-missing", "continue");
expect(outcome).toEqual({
queued: false,
sessionId: "session-missing",
reason: "no_active_run",
gatewayHealth: "live",
});
expect(formatEmbeddedAgentQueueFailureSummary(outcome)).toBe(
"queue_message_failed reason=no_active_run sessionId=session-missing gatewayHealth=live",
);
});
it("returns structured queue failures for legacy, unavailable, or compacting runs", () => {
const legacyQueue = vi.fn(async () => {});
const unavailableQueue = vi.fn(async () => {});
setActiveEmbeddedRun(
"session-not-streaming",
createRunHandle({ isStreaming: false, queueMessage: legacyQueue }),
);
setActiveEmbeddedRun(
"session-unavailable",
createRunHandle({
messageInjection: { isAvailable: () => false, queueMessage: unavailableQueue },
}),
);
setActiveEmbeddedRun("session-compacting", createRunHandle({ isCompacting: true }));
expect(queueEmbeddedAgentMessageWithOutcome("session-not-streaming", "continue")).toMatchObject(
{ queued: false, reason: "not_streaming" },
);
expect(legacyQueue).not.toHaveBeenCalled();
expect(queueEmbeddedAgentMessageWithOutcome("session-unavailable", "continue")).toMatchObject({
queued: false,
reason: "not_streaming",
});
expect(unavailableQueue).not.toHaveBeenCalled();
expect(queueEmbeddedAgentMessageWithOutcome("session-compacting", "continue")).toMatchObject({
queued: false,
reason: "compacting",
});
});
it("returns runtime rejection details when async queue delivery fails", async () => {
setActiveEmbeddedRun("session-rejected", {
...createRunHandle(),
queueMessage: async () => {
throw new Error("cannot steer a compact turn");
},
});
const outcome = await queueEmbeddedAgentMessageWithOutcomeAsync("session-rejected", "continue");
expect(outcome).toEqual({
queued: false,
sessionId: "session-rejected",
reason: "runtime_rejected",
gatewayHealth: "live",
errorMessage: "cannot steer a compact turn",
});
expect(formatEmbeddedAgentQueueFailureSummary(outcome)).toBe(
"queue_message_failed reason=runtime_rejected sessionId=session-rejected gatewayHealth=live error=cannot steer a compact turn",
);
});
it("reports accepted steering without transcript confirmation as non-replayable", async () => {
setActiveEmbeddedRun("session-unconfirmed", {
...createRunHandle(),
queueMessage: async () => ({
transcriptCommit: "unconfirmed",
errorMessage: "receipt unavailable",
}),
});
const outcome = await queueEmbeddedAgentMessageWithOutcomeAsync(
"session-unconfirmed",
"continue",
);
expect(outcome).toEqual({
queued: true,
sessionId: "session-unconfirmed",
target: "embedded_run",
gatewayHealth: "live",
transcriptCommit: "unconfirmed",
errorMessage: "receipt unavailable",
enqueuedAtMs: expect.any(Number),
});
});
it("rejects transcript-commit waits for active handles without support", async () => {
const queueMessage = vi.fn(async () => {});
setActiveEmbeddedRun("session-no-transcript-wait", {
...createRunHandle(),
queueMessage,
});
const outcome = await queueEmbeddedAgentMessageWithOutcomeAsync(
"session-no-transcript-wait",
"continue",
{ waitForTranscriptCommit: true },
);
expect(outcome).toEqual({
queued: false,
sessionId: "session-no-transcript-wait",
reason: "transcript_commit_wait_unsupported",
gatewayHealth: "live",
});
expect(queueMessage).not.toHaveBeenCalled();
});
it("rejects transcript-commit waits before reply-run fallback without an active handle", async () => {
const queueMessage = vi.fn(async () => {});
const operation = createReplyOperation({
sessionKey: "agent:main:main",
sessionId: "session-reply-run",
resetTriggered: false,
});
operation.attachBackend({
kind: "embedded",
cancel: vi.fn(),
isStreaming: () => true,
queueMessage,
});
operation.setPhase("running");
const recorder = createUserTurnTranscriptRecorder({
input: { text: "visible group prompt", sender: { id: "user-42" } },
target: createTestUserTurnTranscriptTarget(),
});
const outcome = await queueEmbeddedAgentMessageWithOutcomeAsync(
"session-reply-run",
"completion from child",
{ waitForTranscriptCommit: true, userTurnTranscriptRecorder: recorder },
);
expect(outcome).toEqual({
queued: false,
sessionId: "session-reply-run",
reason: "transcript_commit_wait_unsupported",
gatewayHealth: "live",
});
expect(queueMessage).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,169 @@
import {
createAssistantMessageEventStream,
type AssistantMessage,
type Model,
} from "openclaw/plugin-sdk/llm";
import { afterEach, beforeEach, vi } from "vitest";
import { createResourceLoader } from "./agent-session-loop-resource-loader.test-support.js";
import { AgentSession } from "./agent-session.js";
import { AuthStorage } from "./auth-storage.js";
import type { ToolDefinition } from "./extensions/types.js";
import { ModelRegistry } from "./model-registry.js";
import type { ResourceLoader } from "./resource-loader.js";
import { createAgentSession, createAgentSessionForEmbeddedRunner } from "./sdk.js";
import { SessionManager } from "./session-manager.js";
import { SettingsManager } from "./settings-manager.js";
const hoistedStreamMocks = vi.hoisted(() => ({
streamSimple: vi.fn(),
}));
export const streamMocks = hoistedStreamMocks;
export const testModel: Model = {
id: "test-model",
name: "Test Model",
api: "openai-responses",
provider: "test-provider",
baseUrl: "https://example.test",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 100,
maxTokens: 100,
};
const sessions: AgentSession[] = [];
function createUsage(contextTokens: number) {
return {
input: contextTokens,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: contextTokens + 1,
contextUsage: {
state: "available" as const,
promptTokens: contextTokens,
totalTokens: contextTokens + 1,
},
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
export function createAssistant(
activeModel: Model,
content: AssistantMessage["content"],
stopReason: AssistantMessage["stopReason"] = "stop",
contextTokens = 1,
): AssistantMessage {
return {
role: "assistant",
content,
api: activeModel.api,
provider: activeModel.provider,
model: activeModel.id,
usage: createUsage(contextTokens),
stopReason,
timestamp: Date.now(),
};
}
export function createAssistantResultStream(message: AssistantMessage) {
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
if (message.stopReason === "error" || message.stopReason === "aborted") {
stream.push({ type: "error", reason: message.stopReason, error: message });
} else {
stream.push({ type: "done", reason: message.stopReason, message });
}
stream.end();
});
return stream;
}
export const createOverflowAssistant = (activeModel: Model) => ({
...createAssistant(activeModel, [{ type: "text", text: "truncated answer" }], "length", 100),
usage: { ...createUsage(100), output: 0 },
});
export const createAutoCompactionSettings = () =>
SettingsManager.inMemory({
compaction: { enabled: true, reserveTokens: 0, keepRecentTokens: 1 },
retry: { enabled: false },
});
export function mockInvalidThenTextSummary(recoveredText: string) {
let requests = 0;
streamMocks.streamSimple.mockImplementation((activeModel: Model) => {
return createAssistantResultStream(
createAssistant(
activeModel,
++requests === 1
? [{ type: "thinking", thinking: "internal summary reasoning" }]
: [{ type: "text", text: recoveredText }],
),
);
});
return () => requests;
}
export async function createTestSession(
options: {
model?: Model;
settingsManager?: SettingsManager;
sessionManager?: SessionManager;
resourceLoader?: ResourceLoader;
customTools?: ToolDefinition[];
contextOverflowRecoveryOwner?: "session" | "caller";
} = {},
) {
const model = options.model ?? testModel;
const authStorage = AuthStorage.inMemory();
authStorage.setRuntimeApiKey(model.provider, "test-api-key");
const settingsManager =
options.settingsManager ??
SettingsManager.inMemory({
compaction: { enabled: false },
retry: { enabled: false },
});
const sessionManager = options.sessionManager ?? SessionManager.inMemory();
const modelRegistry = ModelRegistry.inMemory(authStorage);
modelRegistry.registerProvider(model.provider, {
api: model.api,
streamSimple: streamMocks.streamSimple,
});
const sessionOptions = {
model,
noTools: "builtin" as const,
customTools: options.customTools,
resourceLoader: options.resourceLoader ?? createResourceLoader(),
sessionManager,
settingsManager,
modelRegistry,
};
const result = options.contextOverflowRecoveryOwner
? await createAgentSessionForEmbeddedRunner(sessionOptions, {
contextOverflowRecoveryOwner: options.contextOverflowRecoveryOwner,
})
: await createAgentSession(sessionOptions);
sessions.push(result.session);
return { ...result, settingsManager, sessionManager };
}
export function appendHistory(sessionManager: SessionManager, assistant: AssistantMessage): void {
sessionManager.appendMessage({ role: "user", content: "old prompt", timestamp: Date.now() - 2 });
sessionManager.appendMessage({ ...assistant, timestamp: Date.now() - 1 });
}
export function registerAgentSessionLoopTestLifecycle(): void {
beforeEach(() => {
streamMocks.streamSimple.mockReset();
});
afterEach(() => {
for (const session of sessions.splice(0)) {
session.dispose();
}
});
}
@@ -1,178 +1,33 @@
import {
createAssistantMessageEventStream,
type AssistantMessage,
type Context,
type Model,
type SimpleStreamOptions,
} from "openclaw/plugin-sdk/llm";
import { Type } from "typebox";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const streamMocks = vi.hoisted(() => ({
streamSimple: vi.fn(),
}));
import type { AgentTool } from "../runtime/index.js";
import { describe, expect, it, vi } from "vitest";
import { agentSessionAutomaticCompaction } from "./agent-session-compaction.js";
import {
appendHistory,
createAssistant,
createAssistantResultStream,
createAutoCompactionSettings,
createOverflowAssistant,
createTestSession,
mockInvalidThenTextSummary,
registerAgentSessionLoopTestLifecycle,
streamMocks,
testModel,
} from "./agent-session-loop-correctness.test-support.js";
import {
createCompactionHandlers,
createResourceLoader,
} from "./agent-session-loop-resource-loader.test-support.js";
import type { AgentSessionEvent } from "./agent-session-types.js";
import { AgentSession } from "./agent-session.js";
import { AuthStorage } from "./auth-storage.js";
import type { ToolDefinition } from "./extensions/types.js";
import { ModelRegistry } from "./model-registry.js";
import type { ResourceLoader } from "./resource-loader.js";
import { createAgentSession, createAgentSessionForEmbeddedRunner } from "./sdk.js";
import { SessionManager } from "./session-manager.js";
import { SettingsManager } from "./settings-manager.js";
const testModel: Model = {
id: "test-model",
name: "Test Model",
api: "openai-responses",
provider: "test-provider",
baseUrl: "https://example.test",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 100,
maxTokens: 100,
};
const sessions: AgentSession[] = [];
function createUsage(contextTokens: number) {
return {
input: contextTokens,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: contextTokens + 1,
contextUsage: {
state: "available" as const,
promptTokens: contextTokens,
totalTokens: contextTokens + 1,
},
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
function createAssistant(
activeModel: Model,
content: AssistantMessage["content"],
stopReason: AssistantMessage["stopReason"] = "stop",
contextTokens = 1,
): AssistantMessage {
return {
role: "assistant",
content,
api: activeModel.api,
provider: activeModel.provider,
model: activeModel.id,
usage: createUsage(contextTokens),
stopReason,
timestamp: Date.now(),
};
}
function createAssistantResultStream(message: AssistantMessage) {
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
if (message.stopReason === "error" || message.stopReason === "aborted") {
stream.push({ type: "error", reason: message.stopReason, error: message });
} else {
stream.push({ type: "done", reason: message.stopReason, message });
}
stream.end();
});
return stream;
}
const createOverflowAssistant = (activeModel: Model) => ({
...createAssistant(activeModel, [{ type: "text", text: "truncated answer" }], "length", 100),
usage: { ...createUsage(100), output: 0 },
});
const createAutoCompactionSettings = () =>
SettingsManager.inMemory({
compaction: { enabled: true, reserveTokens: 0, keepRecentTokens: 1 },
retry: { enabled: false },
});
function mockInvalidThenTextSummary(recoveredText: string) {
let requests = 0;
streamMocks.streamSimple.mockImplementation((activeModel: Model) => {
return createAssistantResultStream(
createAssistant(
activeModel,
++requests === 1
? [{ type: "thinking", thinking: "internal summary reasoning" }]
: [{ type: "text", text: recoveredText }],
),
);
});
return () => requests;
}
async function createTestSession(
options: {
model?: Model;
settingsManager?: SettingsManager;
sessionManager?: SessionManager;
resourceLoader?: ResourceLoader;
customTools?: ToolDefinition[];
contextOverflowRecoveryOwner?: "session" | "caller";
} = {},
) {
const model = options.model ?? testModel;
const authStorage = AuthStorage.inMemory();
authStorage.setRuntimeApiKey(model.provider, "test-api-key");
const settingsManager =
options.settingsManager ??
SettingsManager.inMemory({
compaction: { enabled: false },
retry: { enabled: false },
});
const sessionManager = options.sessionManager ?? SessionManager.inMemory();
const modelRegistry = ModelRegistry.inMemory(authStorage);
modelRegistry.registerProvider(model.provider, {
api: model.api,
streamSimple: streamMocks.streamSimple,
});
const sessionOptions = {
model,
noTools: "builtin" as const,
customTools: options.customTools,
resourceLoader: options.resourceLoader ?? createResourceLoader(),
sessionManager,
settingsManager,
modelRegistry,
};
const result = options.contextOverflowRecoveryOwner
? await createAgentSessionForEmbeddedRunner(sessionOptions, {
contextOverflowRecoveryOwner: options.contextOverflowRecoveryOwner,
})
: await createAgentSession(sessionOptions);
sessions.push(result.session);
return { ...result, settingsManager, sessionManager };
}
function appendHistory(sessionManager: SessionManager, assistant: AssistantMessage): void {
sessionManager.appendMessage({ role: "user", content: "old prompt", timestamp: Date.now() - 2 });
sessionManager.appendMessage({ ...assistant, timestamp: Date.now() - 1 });
}
beforeEach(() => {
streamMocks.streamSimple.mockReset();
});
afterEach(() => {
for (const session of sessions.splice(0)) {
session.dispose();
}
});
registerAgentSessionLoopTestLifecycle();
describe("AgentSession loop correctness", () => {
it("carries the canonical assistant entry id through ordered terminal listeners", async () => {
@@ -780,303 +635,4 @@ describe("AgentSession loop correctness", () => {
expect(requests).toHaveLength(1);
expect(JSON.stringify(requests[0]?.messages)).toContain("pending prompt");
});
it("drains a follow-up queued by an agent-end handler", async () => {
const sessionRef: { current?: AgentSession } = {};
let queued = false;
const lifecycleEvents: string[] = [];
const handlers = new Map<string, Array<(...args: unknown[]) => Promise<unknown>>>([
[
"agent_end",
[
async () => {
lifecycleEvents.push("agent_end");
if (!queued) {
queued = true;
await sessionRef.current?.followUp("queued after end");
}
return undefined;
},
],
],
["agent_settled", [async () => lifecycleEvents.push("agent_settled")]],
]);
const requests: Context[] = [];
streamMocks.streamSimple.mockImplementation((activeModel: Model, context: Context) => {
requests.push(context);
return createAssistantResultStream(
createAssistant(activeModel, [{ type: "text", text: `answer ${requests.length}` }]),
);
});
const { session } = await createTestSession({ resourceLoader: createResourceLoader(handlers) });
sessionRef.current = session;
await session.prompt("initial prompt");
expect(requests).toHaveLength(2);
expect(JSON.stringify(requests[1]?.messages)).toContain("queued after end");
expect(session.agent.hasQueuedMessages()).toBe(false);
expect(lifecycleEvents).toEqual(["agent_end", "agent_end", "agent_settled"]);
});
it("leaves queued messages dormant after a turn handoff", async () => {
const sessionRef: { current?: AgentSession } = {};
const settled = vi.fn();
const handlers = new Map<string, Array<(...args: unknown[]) => Promise<unknown>>>([
["agent_settled", [async () => settled()]],
]);
const yieldTool: ToolDefinition = {
name: "yield_turn",
label: "Yield turn",
description: "ends the current turn for an external handoff",
parameters: Type.Object({}),
execute: async () => {
const activeSession = sessionRef.current;
if (!activeSession) {
throw new Error("session not ready");
}
activeSession.agent.steer({
role: "custom",
customType: "test.turn-handoff",
content: "resume only for external delivery",
display: false,
timestamp: Date.now(),
});
activeSession.agent.abort({ code: "turn_handoff", turnHandoff: true });
return { content: [{ type: "text", text: "yielded" }], details: { yielded: true } };
},
};
streamMocks.streamSimple.mockImplementation((activeModel: Model) =>
createAssistantResultStream(
createAssistant(
activeModel,
[{ type: "toolCall", id: "call-yield", name: "yield_turn", arguments: {} }],
"toolUse",
),
),
);
const { session } = await createTestSession({
customTools: [yieldTool],
resourceLoader: createResourceLoader(handlers),
});
sessionRef.current = session;
await session.prompt("yield now");
expect(streamMocks.streamSimple).toHaveBeenCalledOnce();
expect(session.agent.hasQueuedMessages()).toBe(true);
expect(settled).not.toHaveBeenCalled();
session.agent.clearAllQueues();
});
it("applies session model, tool, and prompt changes on the following tool turn", async () => {
const nextModel = { ...testModel, id: "next-model" };
const sessionRef: { current?: AgentSession } = {};
const switchTool: ToolDefinition = {
name: "switch_state",
label: "Switch state",
description: "changes the next turn state",
parameters: Type.Object({}),
execute: async () => {
const activeSession = sessionRef.current;
if (!activeSession) {
throw new Error("session not ready");
}
activeSession.setActiveToolsByName(["second_tool"]);
activeSession.agent.state.model = nextModel;
return { content: [{ type: "text", text: "switched" }], details: {} };
},
};
const secondTool: ToolDefinition = {
name: "second_tool",
label: "Second tool",
description: "available after the switch",
parameters: Type.Object({}),
execute: async () => ({ content: [{ type: "text", text: "done" }], details: {} }),
};
const handlers = new Map<string, Array<(...args: unknown[]) => Promise<unknown>>>([
["before_agent_start", [async () => ({ systemPrompt: "prompt override" })]],
]);
const requests: Array<{ model: string; prompt: string; tools: string[] }> = [];
streamMocks.streamSimple.mockImplementation((activeModel: Model, context: Context) => {
requests.push({
model: activeModel.id,
prompt: context.systemPrompt ?? "",
tools: context.tools?.map((tool) => tool.name) ?? [],
});
const content: AssistantMessage["content"] =
requests.length === 1
? [{ type: "toolCall", id: "call-switch", name: "switch_state", arguments: {} }]
: [{ type: "text", text: "finished" }];
return createAssistantResultStream(
createAssistant(activeModel, content, requests.length === 1 ? "toolUse" : "stop"),
);
});
const { session } = await createTestSession({
resourceLoader: createResourceLoader(handlers),
customTools: [switchTool, secondTool],
});
sessionRef.current = session;
session.setActiveToolsByName(["switch_state"]);
await session.prompt("switch now");
expect(requests).toEqual([
{ model: testModel.id, prompt: "prompt override", tools: ["switch_state"] },
{ model: nextModel.id, prompt: "prompt override", tools: ["second_tool"] },
]);
});
it("preserves explicit updates from an existing next-turn hook", async () => {
const hookModel = { ...testModel, id: "hook-model" };
const hookTool: AgentTool = {
name: "hook_tool",
label: "Hook tool",
description: "provided by the existing turn hook",
parameters: Type.Object({}),
execute: async () => ({ content: [{ type: "text", text: "done" }], details: {} }),
};
const hookContext = {
systemPrompt: "hook prompt",
messages: [],
tools: [hookTool],
};
let returnedUpdate = false;
const { session } = await createTestSession();
session.agent.prepareNextTurn = () => {
if (returnedUpdate) {
return undefined;
}
returnedUpdate = true;
return { context: hookContext, model: hookModel, thinkingLevel: "high" };
};
const contextualHook = session.agent.prepareNextTurnWithContext;
if (!contextualHook) {
throw new Error("context-aware next-turn hook was not installed");
}
const message = createAssistant(testModel, [{ type: "text", text: "turn complete" }]);
const newMessages = [message];
const firstUpdate = await contextualHook({
message,
toolResults: [],
context: { systemPrompt: "loop prompt", messages: [], tools: [] },
newMessages,
});
const secondUpdate = await contextualHook({
message,
toolResults: [],
context: firstUpdate?.context ?? hookContext,
newMessages,
});
for (const update of [firstUpdate, secondUpdate]) {
expect(update).toMatchObject({
context: {
systemPrompt: "hook prompt",
tools: [expect.objectContaining({ name: "hook_tool" })],
},
model: hookModel,
thinkingLevel: "high",
});
}
});
it("preserves fields omitted by an existing next-turn context replacement", async () => {
const sessionTool: AgentTool = {
name: "session_tool",
label: "Session tool",
description: "available in session state",
parameters: Type.Object({}),
execute: async () => ({ content: [{ type: "text", text: "done" }], details: {} }),
};
const initialHook = vi.fn(() => ({
context: { systemPrompt: "stale prompt", messages: [], tools: [sessionTool] },
}));
const replacementHook = vi.fn(() => ({
context: { systemPrompt: "replacement prompt", messages: [] },
}));
const { session } = await createTestSession({ customTools: [sessionTool] });
session.setActiveToolsByName([sessionTool.name]);
session.agent.prepareNextTurn = initialHook;
session.agent.prepareNextTurn = replacementHook;
const message = createAssistant(testModel, [{ type: "text", text: "turn complete" }]);
const contextualHook = session.agent.prepareNextTurnWithContext;
if (!contextualHook) {
throw new Error("context-aware next-turn hook was not installed");
}
const update = await contextualHook({
message,
toolResults: [],
context: { systemPrompt: "loop prompt", messages: [], tools: [sessionTool] },
newMessages: [message],
});
expect(update?.context).toEqual({ systemPrompt: "replacement prompt", messages: [] });
expect(replacementHook).toHaveBeenCalledOnce();
expect(initialHook).not.toHaveBeenCalled();
});
it("aborts in-flight work when disposed", async () => {
let providerSignal: AbortSignal | undefined;
streamMocks.streamSimple.mockImplementation(
(activeModel: Model, _context: Context, options?: SimpleStreamOptions) => {
providerSignal = options?.signal;
const stream = createAssistantMessageEventStream();
options?.signal?.addEventListener(
"abort",
() => {
const message = createAssistant(activeModel, [], "aborted");
stream.push({ type: "error", reason: "aborted", error: message });
stream.end();
},
{ once: true },
);
return stream;
},
);
const { session } = await createTestSession();
const abortRetry = vi.spyOn(session, "abortRetry");
const abortCompaction = vi.spyOn(session, "abortCompaction");
const abortBranchSummary = vi.spyOn(session, "abortBranchSummary");
const abortBash = vi.spyOn(session, "abortBash");
const abortAgent = vi.spyOn(session.agent, "abort");
abortRetry.mockImplementationOnce(() => {
throw new Error("retry abort failed");
});
const prompt = session.prompt("wait");
await vi.waitFor(() => expect(providerSignal).toBeDefined());
session.dispose();
await prompt;
expect(providerSignal?.aborted).toBe(true);
expect(abortRetry).toHaveBeenCalledOnce();
expect(abortCompaction).toHaveBeenCalledOnce();
expect(abortBranchSummary).toHaveBeenCalledOnce();
expect(abortBash).toHaveBeenCalledOnce();
expect(abortAgent).toHaveBeenCalledOnce();
});
it("resynchronizes queue modes when settings reload", async () => {
const settingsManager = SettingsManager.inMemory({
steeringMode: "one-at-a-time",
followUpMode: "one-at-a-time",
compaction: { enabled: false },
retry: { enabled: false },
});
const { session } = await createTestSession({ settingsManager });
settingsManager.setSteeringMode("all");
settingsManager.setFollowUpMode("all");
await settingsManager.flush();
expect(session.agent.steeringMode).toBe("one-at-a-time");
expect(session.agent.followUpMode).toBe("one-at-a-time");
await session.reload();
expect(session.agent.steeringMode).toBe("all");
expect(session.agent.followUpMode).toBe("all");
});
});
@@ -0,0 +1,325 @@
import {
createAssistantMessageEventStream,
type AssistantMessage,
type Context,
type Model,
type SimpleStreamOptions,
} from "openclaw/plugin-sdk/llm";
import { Type } from "typebox";
import { describe, expect, it, vi } from "vitest";
import type { AgentTool } from "../runtime/index.js";
import {
createAssistant,
createAssistantResultStream,
createTestSession,
registerAgentSessionLoopTestLifecycle,
streamMocks,
testModel,
} from "./agent-session-loop-correctness.test-support.js";
import { createResourceLoader } from "./agent-session-loop-resource-loader.test-support.js";
import type { AgentSession } from "./agent-session.js";
import type { ToolDefinition } from "./extensions/types.js";
import { SettingsManager } from "./settings-manager.js";
registerAgentSessionLoopTestLifecycle();
describe("AgentSession queue and next-turn lifecycle correctness", () => {
it("drains a follow-up queued by an agent-end handler", async () => {
const sessionRef: { current?: AgentSession } = {};
let queued = false;
const lifecycleEvents: string[] = [];
const handlers = new Map<string, Array<(...args: unknown[]) => Promise<unknown>>>([
[
"agent_end",
[
async () => {
lifecycleEvents.push("agent_end");
if (!queued) {
queued = true;
await sessionRef.current?.followUp("queued after end");
}
return undefined;
},
],
],
["agent_settled", [async () => lifecycleEvents.push("agent_settled")]],
]);
const requests: Context[] = [];
streamMocks.streamSimple.mockImplementation((activeModel: Model, context: Context) => {
requests.push(context);
return createAssistantResultStream(
createAssistant(activeModel, [{ type: "text", text: `answer ${requests.length}` }]),
);
});
const { session } = await createTestSession({ resourceLoader: createResourceLoader(handlers) });
sessionRef.current = session;
await session.prompt("initial prompt");
expect(requests).toHaveLength(2);
expect(JSON.stringify(requests[1]?.messages)).toContain("queued after end");
expect(session.agent.hasQueuedMessages()).toBe(false);
expect(lifecycleEvents).toEqual(["agent_end", "agent_end", "agent_settled"]);
});
it("leaves queued messages dormant after a turn handoff", async () => {
const sessionRef: { current?: AgentSession } = {};
const settled = vi.fn();
const handlers = new Map<string, Array<(...args: unknown[]) => Promise<unknown>>>([
["agent_settled", [async () => settled()]],
]);
const yieldTool: ToolDefinition = {
name: "yield_turn",
label: "Yield turn",
description: "ends the current turn for an external handoff",
parameters: Type.Object({}),
execute: async () => {
const activeSession = sessionRef.current;
if (!activeSession) {
throw new Error("session not ready");
}
activeSession.agent.steer({
role: "custom",
customType: "test.turn-handoff",
content: "resume only for external delivery",
display: false,
timestamp: Date.now(),
});
activeSession.agent.abort({ code: "turn_handoff", turnHandoff: true });
return { content: [{ type: "text", text: "yielded" }], details: { yielded: true } };
},
};
streamMocks.streamSimple.mockImplementation((activeModel: Model) =>
createAssistantResultStream(
createAssistant(
activeModel,
[{ type: "toolCall", id: "call-yield", name: "yield_turn", arguments: {} }],
"toolUse",
),
),
);
const { session } = await createTestSession({
customTools: [yieldTool],
resourceLoader: createResourceLoader(handlers),
});
sessionRef.current = session;
await session.prompt("yield now");
expect(streamMocks.streamSimple).toHaveBeenCalledOnce();
expect(session.agent.hasQueuedMessages()).toBe(true);
expect(settled).not.toHaveBeenCalled();
session.agent.clearAllQueues();
});
it("applies session model, tool, and prompt changes on the following tool turn", async () => {
const nextModel = { ...testModel, id: "next-model" };
const sessionRef: { current?: AgentSession } = {};
const switchTool: ToolDefinition = {
name: "switch_state",
label: "Switch state",
description: "changes the next turn state",
parameters: Type.Object({}),
execute: async () => {
const activeSession = sessionRef.current;
if (!activeSession) {
throw new Error("session not ready");
}
activeSession.setActiveToolsByName(["second_tool"]);
activeSession.agent.state.model = nextModel;
return { content: [{ type: "text", text: "switched" }], details: {} };
},
};
const secondTool: ToolDefinition = {
name: "second_tool",
label: "Second tool",
description: "available after the switch",
parameters: Type.Object({}),
execute: async () => ({ content: [{ type: "text", text: "done" }], details: {} }),
};
const handlers = new Map<string, Array<(...args: unknown[]) => Promise<unknown>>>([
["before_agent_start", [async () => ({ systemPrompt: "prompt override" })]],
]);
const requests: Array<{ model: string; prompt: string; tools: string[] }> = [];
streamMocks.streamSimple.mockImplementation((activeModel: Model, context: Context) => {
requests.push({
model: activeModel.id,
prompt: context.systemPrompt ?? "",
tools: context.tools?.map((tool) => tool.name) ?? [],
});
const content: AssistantMessage["content"] =
requests.length === 1
? [{ type: "toolCall", id: "call-switch", name: "switch_state", arguments: {} }]
: [{ type: "text", text: "finished" }];
return createAssistantResultStream(
createAssistant(activeModel, content, requests.length === 1 ? "toolUse" : "stop"),
);
});
const { session } = await createTestSession({
resourceLoader: createResourceLoader(handlers),
customTools: [switchTool, secondTool],
});
sessionRef.current = session;
session.setActiveToolsByName(["switch_state"]);
await session.prompt("switch now");
expect(requests).toEqual([
{ model: testModel.id, prompt: "prompt override", tools: ["switch_state"] },
{ model: nextModel.id, prompt: "prompt override", tools: ["second_tool"] },
]);
});
it("preserves explicit updates from an existing next-turn hook", async () => {
const hookModel = { ...testModel, id: "hook-model" };
const hookTool: AgentTool = {
name: "hook_tool",
label: "Hook tool",
description: "provided by the existing turn hook",
parameters: Type.Object({}),
execute: async () => ({ content: [{ type: "text", text: "done" }], details: {} }),
};
const hookContext = {
systemPrompt: "hook prompt",
messages: [],
tools: [hookTool],
};
let returnedUpdate = false;
const { session } = await createTestSession();
session.agent.prepareNextTurn = () => {
if (returnedUpdate) {
return undefined;
}
returnedUpdate = true;
return { context: hookContext, model: hookModel, thinkingLevel: "high" };
};
const contextualHook = session.agent.prepareNextTurnWithContext;
if (!contextualHook) {
throw new Error("context-aware next-turn hook was not installed");
}
const message = createAssistant(testModel, [{ type: "text", text: "turn complete" }]);
const newMessages = [message];
const firstUpdate = await contextualHook({
message,
toolResults: [],
context: { systemPrompt: "loop prompt", messages: [], tools: [] },
newMessages,
});
const secondUpdate = await contextualHook({
message,
toolResults: [],
context: firstUpdate?.context ?? hookContext,
newMessages,
});
for (const update of [firstUpdate, secondUpdate]) {
expect(update).toMatchObject({
context: {
systemPrompt: "hook prompt",
tools: [expect.objectContaining({ name: "hook_tool" })],
},
model: hookModel,
thinkingLevel: "high",
});
}
});
it("preserves fields omitted by an existing next-turn context replacement", async () => {
const sessionTool: AgentTool = {
name: "session_tool",
label: "Session tool",
description: "available in session state",
parameters: Type.Object({}),
execute: async () => ({ content: [{ type: "text", text: "done" }], details: {} }),
};
const initialHook = vi.fn(() => ({
context: { systemPrompt: "stale prompt", messages: [], tools: [sessionTool] },
}));
const replacementHook = vi.fn(() => ({
context: { systemPrompt: "replacement prompt", messages: [] },
}));
const { session } = await createTestSession({ customTools: [sessionTool] });
session.setActiveToolsByName([sessionTool.name]);
session.agent.prepareNextTurn = initialHook;
session.agent.prepareNextTurn = replacementHook;
const message = createAssistant(testModel, [{ type: "text", text: "turn complete" }]);
const contextualHook = session.agent.prepareNextTurnWithContext;
if (!contextualHook) {
throw new Error("context-aware next-turn hook was not installed");
}
const update = await contextualHook({
message,
toolResults: [],
context: { systemPrompt: "loop prompt", messages: [], tools: [sessionTool] },
newMessages: [message],
});
expect(update?.context).toEqual({ systemPrompt: "replacement prompt", messages: [] });
expect(replacementHook).toHaveBeenCalledOnce();
expect(initialHook).not.toHaveBeenCalled();
});
it("aborts in-flight work when disposed", async () => {
let providerSignal: AbortSignal | undefined;
streamMocks.streamSimple.mockImplementation(
(activeModel: Model, _context: Context, options?: SimpleStreamOptions) => {
providerSignal = options?.signal;
const stream = createAssistantMessageEventStream();
options?.signal?.addEventListener(
"abort",
() => {
const message = createAssistant(activeModel, [], "aborted");
stream.push({ type: "error", reason: "aborted", error: message });
stream.end();
},
{ once: true },
);
return stream;
},
);
const { session } = await createTestSession();
const abortRetry = vi.spyOn(session, "abortRetry");
const abortCompaction = vi.spyOn(session, "abortCompaction");
const abortBranchSummary = vi.spyOn(session, "abortBranchSummary");
const abortBash = vi.spyOn(session, "abortBash");
const abortAgent = vi.spyOn(session.agent, "abort");
abortRetry.mockImplementationOnce(() => {
throw new Error("retry abort failed");
});
const prompt = session.prompt("wait");
await vi.waitFor(() => expect(providerSignal).toBeDefined());
session.dispose();
await prompt;
expect(providerSignal?.aborted).toBe(true);
expect(abortRetry).toHaveBeenCalledOnce();
expect(abortCompaction).toHaveBeenCalledOnce();
expect(abortBranchSummary).toHaveBeenCalledOnce();
expect(abortBash).toHaveBeenCalledOnce();
expect(abortAgent).toHaveBeenCalledOnce();
});
it("resynchronizes queue modes when settings reload", async () => {
const settingsManager = SettingsManager.inMemory({
steeringMode: "one-at-a-time",
followUpMode: "one-at-a-time",
compaction: { enabled: false },
retry: { enabled: false },
});
const { session } = await createTestSession({ settingsManager });
settingsManager.setSteeringMode("all");
settingsManager.setFollowUpMode("all");
await settingsManager.flush();
expect(session.agent.steeringMode).toBe("one-at-a-time");
expect(session.agent.followUpMode).toBe("one-at-a-time");
await session.reload();
expect(session.agent.steeringMode).toBe("all");
expect(session.agent.followUpMode).toBe("all");
});
});
@@ -32,6 +32,7 @@ function makeParams(
currentTurn?: NonNullable<SessionEntry["systemPromptReport"]>["currentTurn"];
},
): HandleCommandsParams {
const totalTokensFresh = options?.totalTokensFresh ?? true;
return {
command: {
commandBodyNormalized,
@@ -50,8 +51,8 @@ function makeParams(
sessionEntry: {
...(options?.sessionId ? { sessionId: options.sessionId } : {}),
totalTokens: options?.totalTokens ?? 123,
totalTokensFresh: options?.totalTokensFresh ?? true,
totalTokensVersion: 1 as const,
totalTokensFresh,
...(totalTokensFresh ? { totalTokensVersion: 1 as const } : {}),
inputTokens: 100,
outputTokens: 23,
systemPromptReport: {
+15 -3
View File
@@ -9,6 +9,7 @@ import { isSessionRouteId } from "../app-route-paths.ts";
import { sessionHasPendingApproval } from "../app/approval-presentation.ts";
import { isNativeWebChromeHost } from "../app/native-web-chrome.ts";
import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts";
import { CONTROL_UI_BUILD_INFO } from "../build-info.ts";
import { t } from "../i18n/index.ts";
import { normalizeAgentLabel, resolveAgentTextAvatar } from "../lib/agents/display.ts";
import { deriveAvatarInitial, resolveAgentAvatarUrl } from "../lib/avatar.ts";
@@ -31,6 +32,7 @@ import type { SidebarWorkboardBoard } from "./app-sidebar-workboard.ts";
import { icons } from "./icons.ts";
import { renderSessionGlyph, renderSessionUnreadBadge } from "./session-glyph.ts";
import { renderSessionRowBadges } from "./session-row-badges.ts";
import { formatSidebarBuildSubtitle } from "./sidebar-build-chip-format.ts";
type AppSidebarRenderHost = AppSidebarSessionNavigationElement & {
activePluginTabId: string;
@@ -243,11 +245,17 @@ export function renderAppSidebarFooterBar(host: AppSidebarRenderHost) {
watchedSessions: [],
};
const gateway = host.offline ? null : readSidebarNativeGateway();
const buildSubtitle = formatSidebarBuildSubtitle(CONTROL_UI_BUILD_INFO);
// Health is visual-only here by budget decision; the header picker owns health accessibility.
const gatewayPrimaryTag = gateway?.isPrimary
? t("chat.sessionHeader.gatewayPicker.primaryTag")
: null;
const identityMenuLabel = t("profilePage.identity.menuButtonLabel", { name: selfLabel });
const identityDetail = host.offline
? t("connection.reconnecting")
: gateway
? `${gateway.name}${gatewayPrimaryTag ? `, ${gatewayPrimaryTag}` : ""}`
: buildSubtitle;
return html`
<div class="sidebar-footer-bar">
<openclaw-tooltip .content=${selfLabel}>
@@ -256,8 +264,8 @@ export function renderAppSidebarFooterBar(host: AppSidebarRenderHost) {
class="sidebar-identity-card"
aria-haspopup="menu"
aria-expanded=${String(host.sidebarMenus.identityMenuPosition !== null)}
aria-label=${gateway
? `${identityMenuLabel}: ${gateway.name}${gatewayPrimaryTag ? `, ${gatewayPrimaryTag}` : ""}`
aria-label=${identityDetail
? `${identityMenuLabel}: ${identityDetail}`
: identityMenuLabel}
@click=${(event: MouseEvent) =>
host.sidebarMenus.toggleIdentityMenu(event.currentTarget as HTMLElement)}
@@ -285,7 +293,11 @@ export function renderAppSidebarFooterBar(host: AppSidebarRenderHost) {
>`
: nothing}
</span>`
: nothing}
: buildSubtitle
? html`<span class="sidebar-identity-card__subtitle" aria-hidden="true"
>${buildSubtitle}</span
>`
: nothing}
</span>
<span class="sidebar-identity-card__chevron" aria-hidden="true"
>${icons.chevronDown}</span
+1
View File
@@ -4,6 +4,7 @@ import "../test-helpers/app-sidebar-suite.ts";
import "../test-helpers/app-sidebar-cases/agent-menu.ts";
import "../test-helpers/app-sidebar-cases/attention.ts";
import "../test-helpers/app-sidebar-cases/basics.ts";
import "../test-helpers/app-sidebar-cases/footer-status.ts";
import "../test-helpers/app-sidebar-cases/catalog-compat.ts";
import "../test-helpers/app-sidebar-cases/catalog-live-events.ts";
import "../test-helpers/app-sidebar-cases/catalog-project-activity.ts";
+25 -15
View File
@@ -2,6 +2,7 @@ import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { html, type TemplateResult } from "lit";
import type { ControlUiBuildInfo } from "../build-info.ts";
import { t } from "../i18n/index.ts";
import { formatRelativeTimestamp } from "../lib/format.ts";
const BRANCH_DISPLAY_LENGTH = 14;
@@ -25,28 +26,37 @@ export function formatBuildChipText(info: ControlUiBuildInfo): string | null {
return `${branch}${commit}`;
}
function formatNonReleaseGitIdentity(info: ControlUiBuildInfo): string | null {
if (info.release) {
return null;
}
const compactBuild = formatBuildChipText(info);
if (!compactBuild) {
return null;
}
return info.branch && info.branch !== "main" ? compactBuild : `git@${compactBuild}`;
}
export function formatSidebarBuildSubtitle(info: ControlUiBuildInfo): string | null {
const gitIdentity = formatNonReleaseGitIdentity(info);
if (!gitIdentity) {
return null;
}
const commitAt = info.commitAt ? Date.parse(info.commitAt) : Number.NaN;
return Number.isFinite(commitAt)
? `${gitIdentity} · ${formatRelativeTimestamp(commitAt)}`
: gitIdentity;
}
export function formatSettingsBuildLabel(
info: ControlUiBuildInfo,
gatewayVersion: string | null,
): string | null {
const version = info.version ?? gatewayVersion;
const commit = info.commit;
if (!commit) {
const gitIdentity = formatNonReleaseGitIdentity(info);
if (!gitIdentity) {
return version;
}
const compactBuild = formatBuildChipText(info);
if (!compactBuild) {
return version;
}
if (info.release) {
return version;
}
const gitIdentity =
info.branch && info.branch !== "main"
? compactBuild
: `git@${commit.slice(0, 7)}${info.dirty === true ? "*" : ""}`;
return [version, gitIdentity].filter((value): value is string => Boolean(value)).join(" · ");
}
@@ -1,9 +1,14 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ControlUiBuildInfo } from "../build-info.ts";
import { formatBuildChipText, formatSettingsBuildLabel } from "./sidebar-build-chip-format.ts";
import {
formatBuildChipText,
formatSettingsBuildLabel,
formatSidebarBuildSubtitle,
} from "./sidebar-build-chip-format.ts";
const COMMIT = "e8cbc62f0123456789abcdef0123456789abcdef";
const BUILT_AT = "2026-07-10T12:00:00.000Z";
const NOW = new Date("2026-07-10T16:00:00.000Z");
function buildInfo(overrides: Partial<ControlUiBuildInfo> = {}): ControlUiBuildInfo {
return {
@@ -99,3 +104,39 @@ describe("formatSettingsBuildLabel", () => {
);
});
});
describe("formatSidebarBuildSubtitle", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it("suppresses official release artifacts", () => {
expect(formatSidebarBuildSubtitle(buildInfo({ release: true, commitAt: BUILT_AT }))).toBeNull();
});
it.each([
{ branch: "main", expected: "git@e8cbc62 · 4h ago" },
{ branch: null, expected: "git@e8cbc62 · 4h ago" },
])("formats a custom $branch build with commit age", ({ branch, expected }) => {
expect(formatSidebarBuildSubtitle(buildInfo({ branch, commitAt: BUILT_AT }))).toBe(expected);
});
it("includes branch and dirty state with commit age", () => {
expect(
formatSidebarBuildSubtitle(buildInfo({ branch: "feat/x", dirty: true, commitAt: BUILT_AT })),
).toBe("feat/x@e8cbc62* · 4h ago");
});
it.each([null, "not-a-timestamp"])("keeps the Git identity when commitAt is %s", (commitAt) => {
expect(formatSidebarBuildSubtitle(buildInfo({ commitAt }))).toBe("git@e8cbc62");
});
it("suppresses build identity when the commit is missing", () => {
expect(formatSidebarBuildSubtitle(buildInfo({ commit: null, commitAt: BUILT_AT }))).toBeNull();
});
});
+21 -1
View File
@@ -112,13 +112,33 @@ suite.define(() => {
const response = await page.goto(`${suite.server.baseUrl}chat`);
expect(response?.status()).toBe(200);
const identityCard = page.locator(".sidebar-identity-card");
await expect
.poll(async () => {
const subtitle =
(await identityCard.locator(".sidebar-identity-card__subtitle").textContent()) ?? "";
const [gitIdentity, relativeAge] = subtitle.trim().split(" · ", 2);
return { gitIdentity, hasRelativeAge: Boolean(relativeAge?.trim()) };
})
.toEqual({
gitIdentity: `${COMPACT_BRANCH}@0123456*`,
hasRelativeAge: true,
});
if (captureUiProofEnabled) {
await mkdir(uiProofArtifactDir, { recursive: true });
await identityCard.screenshot({
animations: "disabled",
path: path.join(uiProofArtifactDir, "00-footer-custom-build-identity.png"),
});
}
await openBuildDetails(page);
await assertFullBranchLabel(page);
await page.reload();
await assertFullBranchLabel(page);
if (captureUiProofEnabled) {
await mkdir(uiProofArtifactDir, { recursive: true });
await page.screenshot({
animations: "disabled",
path: path.join(uiProofArtifactDir, "01-about-build-identity.png"),
@@ -1,4 +1,8 @@
import { expect, it } from "vitest";
import {
waitForControlUiGatewayReady,
waitForControlUiGatewayReconnecting,
} from "../test-helpers/control-ui-e2e-readiness.ts";
import {
REFRESHED_RESEARCH_WORKSPACE,
SESSION_LIST_DEFAULTS,
@@ -351,7 +355,7 @@ suite.define(() => {
await page.goto(`${suite.server.baseUrl}new?agent=research`);
await page.getByRole("heading", { name: "Research" }).waitFor();
await gateway.setOnline(false);
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await waitForControlUiGatewayReconnecting(page);
await page.evaluate(() => {
history.pushState(null, "", "new?agent=research&catalog=claude");
@@ -368,6 +372,7 @@ suite.define(() => {
await gateway.deferNext("sessions.catalog.list");
await gateway.setOnline(true);
await waitForControlUiGatewayReady(page);
await gateway.waitForRequest("sessions.catalog.list");
await gateway.deferNext("sessions.catalog.list");
await gateway.rejectDeferred("sessions.catalog.list", {
@@ -459,7 +464,7 @@ suite.define(() => {
const branchRequestsBefore = (await gateway.getRequests("worktrees.branches")).length;
await gateway.setOnline(false);
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await waitForControlUiGatewayReconnecting(page);
await gateway.setMethodResponse("agents.list", {
agents: [
{
@@ -482,6 +487,7 @@ suite.define(() => {
scope: "agent",
});
await gateway.setOnline(true);
await waitForControlUiGatewayReady(page);
await expect
.poll(async () => (await gateway.getRequests("agents.list")).length)
@@ -512,8 +518,9 @@ suite.define(() => {
const branchesBeforeSameWorkspaceReconnect = (await gateway.getRequests("worktrees.branches"))
.length;
await gateway.setOnline(false);
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await waitForControlUiGatewayReconnecting(page);
await gateway.setOnline(true);
await waitForControlUiGatewayReady(page);
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).length)
@@ -1,5 +1,9 @@
import type { BrowserContextOptions, Page } from "playwright";
import { expect, it } from "vitest";
import {
waitForControlUiGatewayReady,
waitForControlUiGatewayReconnecting,
} from "../test-helpers/control-ui-e2e-readiness.ts";
import {
SOURCE_REPO,
TARGET_REPO,
@@ -72,8 +76,9 @@ async function chooseCustomFolder(page: Page, gateway: MockGateway) {
async function reconnectForBranchRediscovery(page: Page, gateway: MockGateway) {
const branchRequests = (await gateway.getRequests("worktrees.branches")).length;
await gateway.setOnline(false);
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await waitForControlUiGatewayReconnecting(page);
await gateway.setOnline(true);
await waitForControlUiGatewayReady(page);
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).length)
.toBe(branchRequests + 1);
@@ -102,8 +107,9 @@ suite.define(() => {
const branchRequests = (await gateway.getRequests("worktrees.branches")).length;
await gateway.deferNext("worktrees.branches");
await gateway.setOnline(false);
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await waitForControlUiGatewayReconnecting(page);
await gateway.setOnline(true);
await waitForControlUiGatewayReady(page);
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).length)
.toBe(branchRequests + 1);
@@ -303,9 +309,10 @@ suite.define(() => {
const nodeRequestsBefore = (await gateway.getRequests("node.list")).length;
await gateway.setOnline(false);
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await waitForControlUiGatewayReconnecting(page);
await gateway.deferNext("node.list");
await gateway.setOnline(true);
await waitForControlUiGatewayReady(page);
await expect
.poll(async () => (await gateway.getRequests("node.list")).length)
.toBe(nodeRequestsBefore + 1);
@@ -456,8 +463,9 @@ suite.define(() => {
} else {
const agentRequestsBefore = (await gateway.getRequests("agents.list")).length;
await gateway.setOnline(false);
await page.locator(".sidebar-identity-card__subtitle").waitFor({ timeout: 10_000 });
await waitForControlUiGatewayReconnecting(page);
await gateway.setOnline(true);
await waitForControlUiGatewayReady(page);
await expect
.poll(async () => (await gateway.getRequests("agents.list")).length)
.toBe(agentRequestsBefore + 1);
+3 -1
View File
@@ -3,6 +3,7 @@ import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { BrowserContext, Page } from "playwright";
import { expect, it } from "vitest";
import { waitForControlUiGatewayReady } from "../test-helpers/control-ui-e2e-readiness.ts";
import { installMockGateway, type MockGatewayControls } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@@ -133,7 +134,8 @@ async function proxyReconnect(
): Promise<void> {
await gateway.closeLatest(1001, "proxy idle timeout");
await expect.poll(() => gateway.getSocketCount(), { timeout: 10_000 }).toBe(expectedSocketCount);
expect(await page.locator(".sidebar-identity-card__subtitle").count()).toBe(0);
await waitForControlUiGatewayReady(page);
expect(await page.locator(".sidebar-identity-card__status").textContent()).toBe("");
}
async function captureProof(page: Page, name: string): Promise<void> {
+1 -110
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { AgentsListResult } from "../../api/types.ts";
import { sessionRefFromPath } from "../../app-session-route-paths.ts";
@@ -22,33 +22,6 @@ import "../../components/app-sidebar.ts";
await import("../../components/viewer-facepile.ts");
type SidebarNativeGatewayTestSnapshot = {
gateways: Array<{
id: string;
name: string;
isPrimary: boolean;
health: "ok" | "error" | "unknown";
}>;
currentId: string;
};
type SidebarNativeGatewayTestWindow = Window & {
__OPENCLAW_NATIVE_WEB_CHROME__?: boolean;
__OPENCLAW_NATIVE_GATEWAYS__?: SidebarNativeGatewayTestSnapshot;
};
function setNativeGatewayTestState(snapshot: SidebarNativeGatewayTestSnapshot): void {
const nativeWindow = window as SidebarNativeGatewayTestWindow;
nativeWindow["__OPENCLAW_NATIVE_WEB_CHROME__"] = true;
nativeWindow["__OPENCLAW_NATIVE_GATEWAYS__"] = snapshot;
}
afterEach(() => {
const nativeWindow = window as SidebarNativeGatewayTestWindow;
Reflect.deleteProperty(nativeWindow, "__OPENCLAW_NATIVE_WEB_CHROME__");
Reflect.deleteProperty(nativeWindow, "__OPENCLAW_NATIVE_GATEWAYS__");
});
describe("AppSidebar update card wiring", () => {
it("keeps OpenClaw out of the workspace sidebar", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
@@ -255,88 +228,6 @@ describe("AppSidebar viewer presence", () => {
});
});
describe("AppSidebar gateway footer subtitle", () => {
const twoGateways = {
gateways: [
{ id: "local", name: "Local Gateway", isPrimary: true, health: "ok" },
{ id: "remote", name: "Remote Gateway", isPrimary: false, health: "unknown" },
],
currentId: "local",
} satisfies SidebarNativeGatewayTestSnapshot;
it("stays hidden outside native chrome", async () => {
const nativeWindow = window as SidebarNativeGatewayTestWindow;
nativeWindow["__OPENCLAW_NATIVE_GATEWAYS__"] = twoGateways;
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
expect(sidebar.querySelector(".sidebar-identity-card__subtitle")).toBeNull();
});
it("stays hidden with one configured gateway", async () => {
setNativeGatewayTestState({ gateways: [twoGateways.gateways[0]!], currentId: "local" });
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
expect(sidebar.querySelector(".sidebar-identity-card__subtitle")).toBeNull();
});
it("shows the current gateway health, name, and primary suffix", async () => {
setNativeGatewayTestState(twoGateways);
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
expect(
sidebar.querySelector(".sidebar-identity-card__gateway-health")?.getAttribute("data-health"),
).toBe("ok");
expect(sidebar.querySelector(".sidebar-identity-card__gateway-name")?.textContent).toBe(
"Local Gateway",
);
expect(sidebar.querySelector(".sidebar-identity-card__gateway-primary")?.textContent).toBe(
"· primary",
);
expect(sidebar.querySelector(".sidebar-identity-card")?.getAttribute("aria-label")).toBe(
"Identity and app menu for Account: Local Gateway, primary",
);
});
it("keeps the reconnecting subtitle while offline", async () => {
setNativeGatewayTestState(twoGateways);
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
sidebar.offline = true;
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-identity-card__subtitle")?.textContent).toBe(
"Reconnecting…",
);
expect(sidebar.querySelector(".sidebar-identity-card__gateway-name")).toBeNull();
});
it("updates when the native gateway snapshot changes", async () => {
setNativeGatewayTestState(twoGateways);
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
setNativeGatewayTestState({
gateways: [
{ id: "local", name: "Local Gateway", isPrimary: true, health: "ok" },
{ id: "remote", name: "Remote Gateway", isPrimary: false, health: "error" },
],
currentId: "remote",
});
window.dispatchEvent(new CustomEvent("openclaw:native-gateways-changed"));
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-identity-card__gateway-name")?.textContent).toBe(
"Remote Gateway",
);
expect(
sidebar.querySelector(".sidebar-identity-card__gateway-health")?.getAttribute("data-health"),
).toBe("error");
expect(sidebar.querySelector(".sidebar-identity-card__gateway-primary")).toBeNull();
});
});
describe("AppSidebar brand actions", () => {
it("starts a thread for the expanded agent from the brand action", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
@@ -0,0 +1,181 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { CONTROL_UI_BUILD_INFO, type ControlUiBuildInfo } from "../../build-info.ts";
import { createGateway, createSessions, mountSidebar } from "../app-sidebar.ts";
import "../../components/app-sidebar.ts";
type SidebarNativeGatewayTestSnapshot = {
gateways: Array<{
id: string;
name: string;
isPrimary: boolean;
health: "ok" | "error" | "unknown";
}>;
currentId: string;
};
type SidebarNativeGatewayTestWindow = Window & {
__OPENCLAW_NATIVE_WEB_CHROME__?: boolean;
__OPENCLAW_NATIVE_GATEWAYS__?: SidebarNativeGatewayTestSnapshot;
};
type MutableControlUiBuildInfo = {
-readonly [Key in keyof ControlUiBuildInfo]: ControlUiBuildInfo[Key];
};
const ORIGINAL_CONTROL_UI_BUILD_INFO = { ...CONTROL_UI_BUILD_INFO };
const CONTROL_UI_TEST_COMMIT = "e8cbc62f0123456789abcdef0123456789abcdef";
function setControlUiBuildInfo(overrides: Partial<ControlUiBuildInfo>): void {
Object.assign(
CONTROL_UI_BUILD_INFO as MutableControlUiBuildInfo,
ORIGINAL_CONTROL_UI_BUILD_INFO,
overrides,
);
}
function setNativeGatewayTestState(snapshot: SidebarNativeGatewayTestSnapshot): void {
const nativeWindow = window as SidebarNativeGatewayTestWindow;
nativeWindow["__OPENCLAW_NATIVE_WEB_CHROME__"] = true;
nativeWindow["__OPENCLAW_NATIVE_GATEWAYS__"] = snapshot;
}
afterEach(() => {
const nativeWindow = window as SidebarNativeGatewayTestWindow;
Reflect.deleteProperty(nativeWindow, "__OPENCLAW_NATIVE_WEB_CHROME__");
Reflect.deleteProperty(nativeWindow, "__OPENCLAW_NATIVE_GATEWAYS__");
Object.assign(CONTROL_UI_BUILD_INFO as MutableControlUiBuildInfo, ORIGINAL_CONTROL_UI_BUILD_INFO);
vi.useRealTimers();
});
describe("AppSidebar gateway footer subtitle", () => {
const twoGateways = {
gateways: [
{ id: "local", name: "Local Gateway", isPrimary: true, health: "ok" },
{ id: "remote", name: "Remote Gateway", isPrimary: false, health: "unknown" },
],
currentId: "local",
} satisfies SidebarNativeGatewayTestSnapshot;
it("shows custom build provenance and hides official releases", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-07-10T16:00:00.000Z"));
setControlUiBuildInfo({
commit: CONTROL_UI_TEST_COMMIT,
commitAt: "2026-07-10T12:00:00.000Z",
branch: "main",
dirty: false,
release: false,
});
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
expect(sidebar.querySelector(".sidebar-identity-card__subtitle")?.textContent).toBe(
"git@e8cbc62 · 4h ago",
);
expect(sidebar.querySelector(".sidebar-identity-card")?.getAttribute("aria-label")).toBe(
"Identity and app menu for Account: git@e8cbc62 · 4h ago",
);
setControlUiBuildInfo({
commit: CONTROL_UI_TEST_COMMIT,
commitAt: "2026-07-10T12:00:00.000Z",
branch: "main",
dirty: false,
release: true,
});
sidebar.requestUpdate();
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-identity-card__subtitle")).toBeNull();
expect(sidebar.querySelector(".sidebar-identity-card")?.getAttribute("aria-label")).toBe(
"Identity and app menu for Account",
);
});
it("stays hidden outside native chrome", async () => {
const nativeWindow = window as SidebarNativeGatewayTestWindow;
nativeWindow["__OPENCLAW_NATIVE_GATEWAYS__"] = twoGateways;
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
expect(sidebar.querySelector(".sidebar-identity-card__subtitle")).toBeNull();
});
it("stays hidden with one configured gateway", async () => {
setNativeGatewayTestState({ gateways: [twoGateways.gateways[0]!], currentId: "local" });
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
expect(sidebar.querySelector(".sidebar-identity-card__subtitle")).toBeNull();
});
it("shows the current gateway health, name, and primary suffix", async () => {
setControlUiBuildInfo({ commit: CONTROL_UI_TEST_COMMIT, release: false });
setNativeGatewayTestState(twoGateways);
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
expect(
sidebar.querySelector(".sidebar-identity-card__gateway-health")?.getAttribute("data-health"),
).toBe("ok");
expect(sidebar.querySelector(".sidebar-identity-card__gateway-name")?.textContent).toBe(
"Local Gateway",
);
expect(sidebar.querySelector(".sidebar-identity-card__gateway-primary")?.textContent).toBe(
"· primary",
);
expect(sidebar.querySelector(".sidebar-identity-card__subtitle")?.textContent).not.toContain(
"git@e8cbc62",
);
expect(sidebar.querySelector(".sidebar-identity-card")?.getAttribute("aria-label")).toBe(
"Identity and app menu for Account: Local Gateway, primary",
);
expect(
sidebar.querySelector(".sidebar-identity-card")?.getAttribute("aria-label"),
).not.toContain("git@e8cbc62");
});
it("keeps the reconnecting subtitle while offline", async () => {
setControlUiBuildInfo({ commit: CONTROL_UI_TEST_COMMIT, release: false });
setNativeGatewayTestState(twoGateways);
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
sidebar.offline = true;
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-identity-card__subtitle")?.textContent).toBe(
"Reconnecting…",
);
expect(sidebar.querySelector(".sidebar-identity-card__gateway-name")).toBeNull();
expect(sidebar.querySelector(".sidebar-identity-card")?.getAttribute("aria-label")).toBe(
"Identity and app menu for Account: Reconnecting…",
);
expect(
sidebar.querySelector(".sidebar-identity-card")?.getAttribute("aria-label"),
).not.toContain("git@e8cbc62");
});
it("updates when the native gateway snapshot changes", async () => {
setNativeGatewayTestState(twoGateways);
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
setNativeGatewayTestState({
gateways: [
{ id: "local", name: "Local Gateway", isPrimary: true, health: "ok" },
{ id: "remote", name: "Remote Gateway", isPrimary: false, health: "error" },
],
currentId: "remote",
});
window.dispatchEvent(new CustomEvent("openclaw:native-gateways-changed"));
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-identity-card__gateway-name")?.textContent).toBe(
"Remote Gateway",
);
expect(
sidebar.querySelector(".sidebar-identity-card__gateway-health")?.getAttribute("data-health"),
).toBe("error");
expect(sidebar.querySelector(".sidebar-identity-card__gateway-primary")).toBeNull();
});
});
@@ -10,6 +10,27 @@ export async function waitForControlUiGatewayReady(page: Page): Promise<void> {
});
}
/** Wait for both the Gateway lifecycle and its dedicated visible reconnect status. */
export async function waitForControlUiGatewayReconnecting(page: Page): Promise<void> {
await Promise.all([
page.waitForFunction(
() => {
const app = document.querySelector("openclaw-app") as
| (HTMLElement & {
runtime?: { context?: { gateway?: { snapshot?: { phase?: string } } } };
})
| null;
return app?.runtime?.context?.gateway?.snapshot?.phase === "reconnecting";
},
undefined,
{ timeout: 10_000 },
),
page
.locator(".sidebar-identity-card__status", { hasText: "Reconnecting…" })
.waitFor({ state: "visible", timeout: 10_000 }),
]);
}
/** Wait for the lazy terminal itself before exercising a real keyboard shortcut. */
export async function waitForControlUiTerminalReady(page: Page): Promise<void> {
await page.waitForFunction(