2026-01-18 08:01:02 +00:00
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
2026-02-18 02:09:40 +01:00
|
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
|
|
|
import type { loadSessionEntry as loadSessionEntryType } from "./session-utils.js";
|
|
|
|
|
|
|
|
|
|
const buildSessionLookup = (
|
|
|
|
|
sessionKey: string,
|
|
|
|
|
entry: {
|
|
|
|
|
sessionId?: string;
|
|
|
|
|
lastChannel?: string;
|
|
|
|
|
lastTo?: string;
|
|
|
|
|
updatedAt?: number;
|
|
|
|
|
} = {},
|
|
|
|
|
): ReturnType<typeof loadSessionEntryType> => ({
|
|
|
|
|
cfg: { session: { mainKey: "agent:main:main" } } as OpenClawConfig,
|
|
|
|
|
storePath: "/tmp/sessions.json",
|
|
|
|
|
store: {} as ReturnType<typeof loadSessionEntryType>["store"],
|
|
|
|
|
entry: {
|
|
|
|
|
sessionId: entry.sessionId ?? `sid-${sessionKey}`,
|
|
|
|
|
updatedAt: entry.updatedAt ?? Date.now(),
|
|
|
|
|
lastChannel: entry.lastChannel,
|
|
|
|
|
lastTo: entry.lastTo,
|
|
|
|
|
},
|
|
|
|
|
canonicalKey: sessionKey,
|
|
|
|
|
legacyKey: undefined,
|
|
|
|
|
});
|
2026-01-18 08:01:02 +00:00
|
|
|
|
|
|
|
|
vi.mock("../infra/system-events.js", () => ({
|
|
|
|
|
enqueueSystemEvent: vi.fn(),
|
|
|
|
|
}));
|
|
|
|
|
vi.mock("../infra/heartbeat-wake.js", () => ({
|
|
|
|
|
requestHeartbeatNow: vi.fn(),
|
|
|
|
|
}));
|
2026-02-17 13:02:38 +00:00
|
|
|
vi.mock("../commands/agent.js", () => ({
|
|
|
|
|
agentCommand: vi.fn(),
|
|
|
|
|
}));
|
|
|
|
|
vi.mock("../config/config.js", () => ({
|
|
|
|
|
loadConfig: vi.fn(() => ({ session: { mainKey: "agent:main:main" } })),
|
2026-02-17 20:08:50 +00:00
|
|
|
STATE_DIR: "/tmp/openclaw-state",
|
2026-02-17 13:02:38 +00:00
|
|
|
}));
|
|
|
|
|
vi.mock("../config/sessions.js", () => ({
|
|
|
|
|
updateSessionStore: vi.fn(),
|
|
|
|
|
}));
|
|
|
|
|
vi.mock("./session-utils.js", () => ({
|
2026-02-18 02:09:40 +01:00
|
|
|
loadSessionEntry: vi.fn((sessionKey: string) => buildSessionLookup(sessionKey)),
|
2026-02-17 13:02:38 +00:00
|
|
|
pruneLegacyStoreKeys: vi.fn(),
|
|
|
|
|
resolveGatewaySessionStoreTarget: vi.fn(({ key }: { key: string }) => ({
|
|
|
|
|
canonicalKey: key,
|
|
|
|
|
storeKeys: [key],
|
|
|
|
|
})),
|
|
|
|
|
}));
|
2026-01-18 08:01:02 +00:00
|
|
|
|
2026-02-01 10:03:47 +09:00
|
|
|
import type { CliDeps } from "../cli/deps.js";
|
2026-02-17 23:47:24 +01:00
|
|
|
import { agentCommand } from "../commands/agent.js";
|
2026-02-18 02:09:40 +01:00
|
|
|
import type { HealthSummary } from "../commands/health.js";
|
2026-02-17 13:02:38 +00:00
|
|
|
import { updateSessionStore } from "../config/sessions.js";
|
2026-01-18 08:01:02 +00:00
|
|
|
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
2026-02-18 02:09:40 +01:00
|
|
|
import type { NodeEventContext } from "./server-node-events-types.js";
|
2026-01-19 04:50:07 +00:00
|
|
|
import { handleNodeEvent } from "./server-node-events.js";
|
2026-02-17 23:47:24 +01:00
|
|
|
import { loadSessionEntry } from "./session-utils.js";
|
2026-01-18 08:01:02 +00:00
|
|
|
|
|
|
|
|
const enqueueSystemEventMock = vi.mocked(enqueueSystemEvent);
|
|
|
|
|
const requestHeartbeatNowMock = vi.mocked(requestHeartbeatNow);
|
2026-02-17 13:02:38 +00:00
|
|
|
const agentCommandMock = vi.mocked(agentCommand);
|
|
|
|
|
const updateSessionStoreMock = vi.mocked(updateSessionStore);
|
2026-02-17 23:47:24 +01:00
|
|
|
const loadSessionEntryMock = vi.mocked(loadSessionEntry);
|
2026-01-18 08:01:02 +00:00
|
|
|
|
2026-01-19 04:50:07 +00:00
|
|
|
function buildCtx(): NodeEventContext {
|
2026-01-18 08:01:02 +00:00
|
|
|
return {
|
|
|
|
|
deps: {} as CliDeps,
|
|
|
|
|
broadcast: () => {},
|
2026-01-19 04:50:07 +00:00
|
|
|
nodeSendToSession: () => {},
|
|
|
|
|
nodeSubscribe: () => {},
|
|
|
|
|
nodeUnsubscribe: () => {},
|
2026-01-18 08:01:02 +00:00
|
|
|
broadcastVoiceWakeChanged: () => {},
|
|
|
|
|
addChatRun: () => {},
|
|
|
|
|
removeChatRun: () => undefined,
|
|
|
|
|
chatAbortControllers: new Map(),
|
|
|
|
|
chatAbortedRuns: new Map(),
|
|
|
|
|
chatRunBuffers: new Map(),
|
|
|
|
|
chatDeltaSentAt: new Map(),
|
|
|
|
|
dedupe: new Map(),
|
|
|
|
|
agentRunSeq: new Map(),
|
|
|
|
|
getHealthCache: () => null,
|
|
|
|
|
refreshHealthSnapshot: async () => ({}) as HealthSummary,
|
|
|
|
|
loadGatewayModelCatalog: async () => [],
|
2026-01-19 04:50:07 +00:00
|
|
|
logGateway: { warn: () => {} },
|
2026-01-18 08:01:02 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 04:50:07 +00:00
|
|
|
describe("node exec events", () => {
|
2026-01-18 08:01:02 +00:00
|
|
|
beforeEach(() => {
|
|
|
|
|
enqueueSystemEventMock.mockReset();
|
|
|
|
|
requestHeartbeatNowMock.mockReset();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("enqueues exec.started events", async () => {
|
|
|
|
|
const ctx = buildCtx();
|
2026-01-19 04:50:07 +00:00
|
|
|
await handleNodeEvent(ctx, "node-1", {
|
2026-01-18 08:01:02 +00:00
|
|
|
event: "exec.started",
|
|
|
|
|
payloadJSON: JSON.stringify({
|
|
|
|
|
sessionKey: "agent:main:main",
|
|
|
|
|
runId: "run-1",
|
|
|
|
|
command: "ls -la",
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
|
|
|
|
|
"Exec started (node=node-1 id=run-1): ls -la",
|
|
|
|
|
{ sessionKey: "agent:main:main", contextKey: "exec:run-1" },
|
|
|
|
|
);
|
|
|
|
|
expect(requestHeartbeatNowMock).toHaveBeenCalledWith({ reason: "exec-event" });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("enqueues exec.finished events with output", async () => {
|
|
|
|
|
const ctx = buildCtx();
|
2026-01-19 04:50:07 +00:00
|
|
|
await handleNodeEvent(ctx, "node-2", {
|
2026-01-18 08:01:02 +00:00
|
|
|
event: "exec.finished",
|
|
|
|
|
payloadJSON: JSON.stringify({
|
|
|
|
|
runId: "run-2",
|
|
|
|
|
exitCode: 0,
|
|
|
|
|
timedOut: false,
|
|
|
|
|
output: "done",
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
|
|
|
|
|
"Exec finished (node=node-2 id=run-2, code 0)\ndone",
|
|
|
|
|
{ sessionKey: "node-node-2", contextKey: "exec:run-2" },
|
|
|
|
|
);
|
|
|
|
|
expect(requestHeartbeatNowMock).toHaveBeenCalledWith({ reason: "exec-event" });
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-14 18:32:45 -05:00
|
|
|
it("suppresses noisy exec.finished success events with empty output", async () => {
|
|
|
|
|
const ctx = buildCtx();
|
|
|
|
|
await handleNodeEvent(ctx, "node-2", {
|
|
|
|
|
event: "exec.finished",
|
|
|
|
|
payloadJSON: JSON.stringify({
|
|
|
|
|
runId: "run-quiet",
|
|
|
|
|
exitCode: 0,
|
|
|
|
|
timedOut: false,
|
|
|
|
|
output: " ",
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
|
|
|
|
expect(requestHeartbeatNowMock).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("truncates long exec.finished output in system events", async () => {
|
|
|
|
|
const ctx = buildCtx();
|
|
|
|
|
await handleNodeEvent(ctx, "node-2", {
|
|
|
|
|
event: "exec.finished",
|
|
|
|
|
payloadJSON: JSON.stringify({
|
|
|
|
|
runId: "run-long",
|
|
|
|
|
exitCode: 0,
|
|
|
|
|
timedOut: false,
|
|
|
|
|
output: "x".repeat(600),
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const [[text]] = enqueueSystemEventMock.mock.calls;
|
|
|
|
|
expect(typeof text).toBe("string");
|
|
|
|
|
expect(text.startsWith("Exec finished (node=node-2 id=run-long, code 0)\n")).toBe(true);
|
|
|
|
|
expect(text.endsWith("…")).toBe(true);
|
|
|
|
|
expect(text.length).toBeLessThan(280);
|
|
|
|
|
expect(requestHeartbeatNowMock).toHaveBeenCalledWith({ reason: "exec-event" });
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-18 08:01:02 +00:00
|
|
|
it("enqueues exec.denied events with reason", async () => {
|
|
|
|
|
const ctx = buildCtx();
|
2026-01-19 04:50:07 +00:00
|
|
|
await handleNodeEvent(ctx, "node-3", {
|
2026-01-18 08:01:02 +00:00
|
|
|
event: "exec.denied",
|
|
|
|
|
payloadJSON: JSON.stringify({
|
|
|
|
|
sessionKey: "agent:demo:main",
|
|
|
|
|
runId: "run-3",
|
|
|
|
|
command: "rm -rf /",
|
|
|
|
|
reason: "allowlist-miss",
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
|
|
|
|
|
"Exec denied (node=node-3 id=run-3, allowlist-miss): rm -rf /",
|
|
|
|
|
{ sessionKey: "agent:demo:main", contextKey: "exec:run-3" },
|
|
|
|
|
);
|
|
|
|
|
expect(requestHeartbeatNowMock).toHaveBeenCalledWith({ reason: "exec-event" });
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-02-17 13:02:38 +00:00
|
|
|
|
|
|
|
|
describe("voice transcript events", () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
agentCommandMock.mockReset();
|
|
|
|
|
updateSessionStoreMock.mockReset();
|
|
|
|
|
agentCommandMock.mockResolvedValue({ status: "ok" } as never);
|
|
|
|
|
updateSessionStoreMock.mockImplementation(async (_storePath, update) => {
|
|
|
|
|
update({});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("dedupes repeated transcript payloads for the same session", async () => {
|
|
|
|
|
const addChatRun = vi.fn();
|
|
|
|
|
const ctx = buildCtx();
|
|
|
|
|
ctx.addChatRun = addChatRun;
|
|
|
|
|
|
|
|
|
|
const payload = {
|
|
|
|
|
text: "hello from mic",
|
|
|
|
|
sessionKey: "voice-dedupe-session",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await handleNodeEvent(ctx, "node-v1", {
|
|
|
|
|
event: "voice.transcript",
|
|
|
|
|
payloadJSON: JSON.stringify(payload),
|
|
|
|
|
});
|
|
|
|
|
await handleNodeEvent(ctx, "node-v1", {
|
|
|
|
|
event: "voice.transcript",
|
|
|
|
|
payloadJSON: JSON.stringify(payload),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(agentCommandMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(addChatRun).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(updateSessionStoreMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not dedupe identical text when source event IDs differ", async () => {
|
|
|
|
|
const ctx = buildCtx();
|
|
|
|
|
|
|
|
|
|
await handleNodeEvent(ctx, "node-v1", {
|
|
|
|
|
event: "voice.transcript",
|
|
|
|
|
payloadJSON: JSON.stringify({
|
|
|
|
|
text: "hello from mic",
|
|
|
|
|
sessionKey: "voice-dedupe-eventid-session",
|
|
|
|
|
eventId: "evt-voice-1",
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
await handleNodeEvent(ctx, "node-v1", {
|
|
|
|
|
event: "voice.transcript",
|
|
|
|
|
payloadJSON: JSON.stringify({
|
|
|
|
|
text: "hello from mic",
|
|
|
|
|
sessionKey: "voice-dedupe-eventid-session",
|
|
|
|
|
eventId: "evt-voice-2",
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(agentCommandMock).toHaveBeenCalledTimes(2);
|
|
|
|
|
expect(updateSessionStoreMock).toHaveBeenCalledTimes(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("forwards transcript with voice provenance", async () => {
|
|
|
|
|
const ctx = buildCtx();
|
|
|
|
|
|
|
|
|
|
await handleNodeEvent(ctx, "node-v2", {
|
|
|
|
|
event: "voice.transcript",
|
|
|
|
|
payloadJSON: JSON.stringify({
|
|
|
|
|
text: "check provenance",
|
|
|
|
|
sessionKey: "voice-provenance-session",
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(agentCommandMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
const [opts] = agentCommandMock.mock.calls[0] ?? [];
|
|
|
|
|
expect(opts).toMatchObject({
|
|
|
|
|
message: "check provenance",
|
|
|
|
|
deliver: false,
|
|
|
|
|
messageChannel: "node",
|
|
|
|
|
inputProvenance: {
|
|
|
|
|
kind: "external_user",
|
|
|
|
|
sourceChannel: "voice",
|
|
|
|
|
sourceTool: "gateway.voice.transcript",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not block agent dispatch when session-store touch fails", async () => {
|
|
|
|
|
const warn = vi.fn();
|
|
|
|
|
const ctx = buildCtx();
|
|
|
|
|
ctx.logGateway = { warn };
|
|
|
|
|
updateSessionStoreMock.mockRejectedValueOnce(new Error("disk down"));
|
|
|
|
|
|
|
|
|
|
await handleNodeEvent(ctx, "node-v3", {
|
|
|
|
|
event: "voice.transcript",
|
|
|
|
|
payloadJSON: JSON.stringify({
|
|
|
|
|
text: "continue anyway",
|
|
|
|
|
sessionKey: "voice-store-fail-session",
|
|
|
|
|
}),
|
|
|
|
|
});
|
fix(subagent): harden read-tool overflow guards and sticky reply threading (#19508)
* fix(gateway): avoid premature agent.wait completion on transient errors
* fix(agent): preemptively guard tool results against context overflow
* fix: harden tool-result context guard and add message_id metadata
* fix: use importOriginal in session-key mock to include DEFAULT_ACCOUNT_ID
The run.skill-filter test was mocking ../../routing/session-key.js with only
buildAgentMainSessionKey and normalizeAgentId, but the module also exports
DEFAULT_ACCOUNT_ID which is required transitively by src/web/auth-store.ts.
Switch to importOriginal pattern so all real exports are preserved alongside
the mocked functions.
* pi-runner: guard accumulated tool-result overflow in transformContext
* PI runner: compact overflowing tool-result context
* Subagent: harden tool-result context recovery
* Enhance tool-result context handling by adding support for legacy tool outputs and improving character estimation for message truncation. This includes a new function to create legacy tool results and updates to existing functions to better manage context overflow scenarios.
* Enhance iMessage handling by adding reply tag support in send functions and tests. This includes modifications to prepend or rewrite reply tags based on provided replyToId, ensuring proper message formatting for replies.
* Enhance message delivery across multiple channels by implementing sticky reply context for chunked messages. This includes preserving reply references in Discord, Telegram, and iMessage, ensuring that follow-up messages maintain their intended reply targets. Additionally, improve handling of reply tags in system prompts and tests to support consistent reply behavior.
* Enhance read tool functionality by implementing auto-paging across chunks when no explicit limit is provided, scaling output budget based on model context window. Additionally, add tests for adaptive reading behavior and capped continuation guidance for large outputs. Update related functions to support these features.
* Refine tool-result context management by stripping oversized read-tool details payloads during compaction, ensuring repeated read calls do not bypass context limits. Introduce new utility functions for handling truncation content and enhance character estimation for tool results. Add tests to validate the removal of excessive details in context overflow scenarios.
* Refine message delivery logic in Matrix and Telegram by introducing a flag to track if a text chunk was sent. This ensures that replies are only marked as delivered when a text chunk has been successfully sent, improving the accuracy of reply handling in both channels.
* fix: tighten reply threading coverage and prep fixes (#19508) (thanks @tyler6204)
2026-02-17 15:32:52 -08:00
|
|
|
await Promise.resolve();
|
2026-02-17 13:02:38 +00:00
|
|
|
|
|
|
|
|
expect(agentCommandMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining("voice session-store update failed"));
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-02-17 23:47:24 +01:00
|
|
|
|
|
|
|
|
describe("agent request events", () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
agentCommandMock.mockReset();
|
|
|
|
|
updateSessionStoreMock.mockReset();
|
|
|
|
|
loadSessionEntryMock.mockReset();
|
|
|
|
|
agentCommandMock.mockResolvedValue({ status: "ok" } as never);
|
|
|
|
|
updateSessionStoreMock.mockImplementation(async (_storePath, update) => {
|
|
|
|
|
update({});
|
|
|
|
|
});
|
2026-02-18 02:09:40 +01:00
|
|
|
loadSessionEntryMock.mockImplementation((sessionKey: string) => buildSessionLookup(sessionKey));
|
2026-02-17 23:47:24 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("disables delivery when route is unresolved instead of falling back globally", async () => {
|
|
|
|
|
const warn = vi.fn();
|
|
|
|
|
const ctx = buildCtx();
|
|
|
|
|
ctx.logGateway = { warn };
|
|
|
|
|
|
|
|
|
|
await handleNodeEvent(ctx, "node-route-miss", {
|
|
|
|
|
event: "agent.request",
|
|
|
|
|
payloadJSON: JSON.stringify({
|
|
|
|
|
message: "summarize this",
|
|
|
|
|
sessionKey: "agent:main:main",
|
|
|
|
|
deliver: true,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(agentCommandMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
const [opts] = agentCommandMock.mock.calls[0] ?? [];
|
|
|
|
|
expect(opts).toMatchObject({
|
|
|
|
|
message: "summarize this",
|
|
|
|
|
sessionKey: "agent:main:main",
|
|
|
|
|
deliver: false,
|
|
|
|
|
channel: undefined,
|
|
|
|
|
to: undefined,
|
|
|
|
|
});
|
|
|
|
|
expect(warn).toHaveBeenCalledWith(
|
|
|
|
|
expect.stringContaining("agent delivery disabled node=node-route-miss"),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("reuses the current session route when delivery target is omitted", async () => {
|
|
|
|
|
const ctx = buildCtx();
|
|
|
|
|
loadSessionEntryMock.mockReturnValueOnce({
|
2026-02-18 02:09:40 +01:00
|
|
|
...buildSessionLookup("agent:main:main", {
|
2026-02-17 23:47:24 +01:00
|
|
|
sessionId: "sid-current",
|
|
|
|
|
lastChannel: "telegram",
|
|
|
|
|
lastTo: "123",
|
2026-02-18 02:09:40 +01:00
|
|
|
}),
|
2026-02-17 23:47:24 +01:00
|
|
|
canonicalKey: "agent:main:main",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await handleNodeEvent(ctx, "node-route-hit", {
|
|
|
|
|
event: "agent.request",
|
|
|
|
|
payloadJSON: JSON.stringify({
|
|
|
|
|
message: "route on session",
|
|
|
|
|
sessionKey: "agent:main:main",
|
|
|
|
|
deliver: true,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(agentCommandMock).toHaveBeenCalledTimes(1);
|
|
|
|
|
const [opts] = agentCommandMock.mock.calls[0] ?? [];
|
|
|
|
|
expect(opts).toMatchObject({
|
|
|
|
|
message: "route on session",
|
|
|
|
|
sessionKey: "agent:main:main",
|
|
|
|
|
deliver: true,
|
|
|
|
|
channel: "telegram",
|
|
|
|
|
to: "123",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|