First Release of Claw3D (#11)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,963 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createGatewayRuntimeEventHandler } from "@/features/agents/state/gatewayRuntimeEventHandler";
|
||||
import {
|
||||
agentStoreReducer,
|
||||
initialAgentStoreState,
|
||||
type AgentState,
|
||||
type AgentStoreSeed,
|
||||
} from "@/features/agents/state/store";
|
||||
import * as transcriptState from "@/features/agents/state/transcript";
|
||||
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
const createAgent = (overrides?: Partial<AgentState>): AgentState => {
|
||||
const base: AgentState = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:studio:test-session",
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
};
|
||||
const merged = { ...base, ...(overrides ?? {}) };
|
||||
|
||||
return {
|
||||
...merged,
|
||||
historyFetchLimit: merged.historyFetchLimit ?? null,
|
||||
historyFetchedCount: merged.historyFetchedCount ?? null,
|
||||
historyMaybeTruncated: merged.historyMaybeTruncated ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
describe("gateway runtime event handler (chat)", () => {
|
||||
it("applies delta assistant chat stream via queueLivePatch", () => {
|
||||
const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })];
|
||||
const dispatch = vi.fn();
|
||||
const queueLivePatch = vi.fn();
|
||||
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch,
|
||||
queueLivePatch,
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
const event: EventFrame = {
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "delta",
|
||||
message: { role: "assistant", content: "Hello" },
|
||||
},
|
||||
};
|
||||
|
||||
handler.handleEvent(event);
|
||||
|
||||
expect(queueLivePatch).toHaveBeenCalledTimes(1);
|
||||
expect(queueLivePatch).toHaveBeenCalledWith(
|
||||
"agent-1",
|
||||
expect.objectContaining({
|
||||
streamText: "Hello",
|
||||
status: "running",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores user/system roles for streaming output", () => {
|
||||
const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })];
|
||||
const queueLivePatch = vi.fn();
|
||||
const dispatch = vi.fn();
|
||||
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch,
|
||||
queueLivePatch,
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "delta",
|
||||
message: { role: "user", content: "Hello" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(queueLivePatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores stale delta chat events for non-active runIds", () => {
|
||||
const agents = [
|
||||
createAgent({
|
||||
status: "running",
|
||||
runId: "run-2",
|
||||
runStartedAt: 900,
|
||||
}),
|
||||
];
|
||||
const queueLivePatch = vi.fn();
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: vi.fn(),
|
||||
queueLivePatch,
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "delta",
|
||||
message: { role: "assistant", content: "stale text" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(queueLivePatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies final assistant chat by appending output and clearing stream fields", async () => {
|
||||
const agents = [
|
||||
createAgent({
|
||||
lastUserMessage: "hello",
|
||||
latestOverride: null,
|
||||
status: "running",
|
||||
runId: "run-1",
|
||||
runStartedAt: 900,
|
||||
}),
|
||||
];
|
||||
const dispatched: Array<{ type: string; agentId: string; line?: string; patch?: unknown }> = [];
|
||||
const dispatch = vi.fn((action) => {
|
||||
dispatched.push(action as never);
|
||||
});
|
||||
const updateSpecialLatestUpdate = vi.fn();
|
||||
const clearPendingLivePatch = vi.fn();
|
||||
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch,
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch,
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate,
|
||||
});
|
||||
|
||||
const ts = "2024-01-01T00:00:00.000Z";
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: { role: "assistant", content: "Done", timestamp: ts, thinking: "t" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(dispatched.some((entry) => entry.type === "appendOutput" && entry.line === "Done")).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
dispatched.some((entry) => {
|
||||
if (entry.type !== "updateAgent") return false;
|
||||
const patch = entry.patch as Record<string, unknown>;
|
||||
return patch.streamText === null && patch.thinkingTrace === null;
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
dispatched.some((entry) => {
|
||||
if (entry.type !== "updateAgent") return false;
|
||||
const patch = entry.patch as Record<string, unknown>;
|
||||
return patch.status === "idle" && patch.runId === null;
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
dispatched.some((entry) => {
|
||||
if (entry.type !== "updateAgent") return false;
|
||||
const patch = entry.patch as Record<string, unknown>;
|
||||
return patch.lastAssistantMessageAt === Date.parse(ts);
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(updateSpecialLatestUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(updateSpecialLatestUpdate).toHaveBeenCalledWith("agent-1", agents[0], "hello");
|
||||
expect(clearPendingLivePatch).toHaveBeenCalledWith("agent-1");
|
||||
});
|
||||
|
||||
it("uses the current chat agent snapshot for latest-update effects", () => {
|
||||
const agents = [
|
||||
createAgent({
|
||||
lastUserMessage: "hello",
|
||||
latestOverride: null,
|
||||
status: "running",
|
||||
runId: "run-1",
|
||||
runStartedAt: 900,
|
||||
}),
|
||||
];
|
||||
let getAgentsCalls = 0;
|
||||
const getAgents = () => {
|
||||
getAgentsCalls += 1;
|
||||
return getAgentsCalls === 1 ? agents : [];
|
||||
};
|
||||
const updateSpecialLatestUpdate = vi.fn();
|
||||
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents,
|
||||
dispatch: vi.fn(),
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate,
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: { role: "assistant", content: "Done", timestamp: "2024-01-01T00:00:00.000Z" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateSpecialLatestUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(updateSpecialLatestUpdate).toHaveBeenCalledWith("agent-1", agents[0], "hello");
|
||||
});
|
||||
|
||||
it("normalizes markdown-rich final assistant chat text before append and lastResult update", () => {
|
||||
const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })];
|
||||
const dispatched: Array<{ type: string; line?: string; patch?: unknown }> = [];
|
||||
const normalizedAssistantText = ["- item one", "- item two", "", "```ts", "const n = 1;", "```"].join(
|
||||
"\n"
|
||||
);
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: vi.fn((action) => {
|
||||
dispatched.push(action as never);
|
||||
}),
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "\n- item one \r\n- item two\t \r\n\r\n\r\n```ts \r\nconst n = 1;\t\r\n```\r\n\r\n",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
dispatched.some(
|
||||
(entry) => entry.type === "appendOutput" && entry.line === normalizedAssistantText
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
dispatched.some((entry) => {
|
||||
if (entry.type !== "updateAgent") return false;
|
||||
const patch = entry.patch as Record<string, unknown>;
|
||||
return patch.lastResult === normalizedAssistantText;
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requests history refresh through boundary command only when final assistant arrives without trace lines", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const agents = [createAgent({ outputLines: [] })];
|
||||
const requestHistoryRefresh = vi.fn(async () => {});
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: vi.fn(),
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh,
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: { role: "assistant", content: "Done" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestHistoryRefresh).not.toHaveBeenCalled();
|
||||
vi.runAllTimers();
|
||||
expect(requestHistoryRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(requestHistoryRefresh).toHaveBeenCalledWith({
|
||||
agentId: "agent-1",
|
||||
reason: "chat-final-no-trace",
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces committed lifecycle fallback with canonical chat final in reducer state", () => {
|
||||
vi.useFakeTimers();
|
||||
const metricSpy = vi
|
||||
.spyOn(transcriptState, "logTranscriptDebugMetric")
|
||||
.mockImplementation(() => {});
|
||||
try {
|
||||
const agents = [
|
||||
createAgent({
|
||||
status: "running",
|
||||
runId: "run-1",
|
||||
runStartedAt: 900,
|
||||
streamText: "fallback final",
|
||||
}),
|
||||
];
|
||||
const dispatched: Array<Record<string, unknown>> = [];
|
||||
const dispatch = vi.fn((action) => {
|
||||
dispatched.push(action as never);
|
||||
});
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch,
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
stream: "lifecycle",
|
||||
data: { phase: "end" },
|
||||
},
|
||||
} as EventFrame);
|
||||
vi.advanceTimersByTime(400);
|
||||
|
||||
expect(
|
||||
dispatched.some(
|
||||
(entry) =>
|
||||
entry.type === "appendOutput" &&
|
||||
entry.line === "fallback final" &&
|
||||
typeof entry.transcript === "object"
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
const event: EventFrame = {
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: { role: "assistant", content: "canonical final" },
|
||||
},
|
||||
};
|
||||
handler.handleEvent(event);
|
||||
|
||||
const seed: AgentStoreSeed = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
};
|
||||
let state = agentStoreReducer(initialAgentStoreState, {
|
||||
type: "hydrateAgents",
|
||||
agents: [seed],
|
||||
});
|
||||
state = agentStoreReducer(state, {
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: {
|
||||
status: "running",
|
||||
runId: "run-1",
|
||||
runStartedAt: 900,
|
||||
streamText: "fallback final",
|
||||
},
|
||||
});
|
||||
for (const action of dispatched) {
|
||||
if (!action || typeof action !== "object") continue;
|
||||
if (typeof (action as { type?: unknown }).type !== "string") continue;
|
||||
state = agentStoreReducer(state, action as never);
|
||||
}
|
||||
const agentState = state.agents.find((entry) => entry.agentId === "agent-1");
|
||||
const transcriptEntries = agentState?.transcriptEntries ?? [];
|
||||
const assistantEntries = transcriptEntries.filter((entry) => entry.kind === "assistant");
|
||||
const assistantMetaEntries = transcriptEntries.filter(
|
||||
(entry) => entry.kind === "meta" && entry.role === "assistant"
|
||||
);
|
||||
|
||||
expect(assistantEntries).toHaveLength(1);
|
||||
expect(assistantEntries[0]?.text).toBe("canonical final");
|
||||
expect(assistantMetaEntries).toHaveLength(1);
|
||||
expect(metricSpy).toHaveBeenCalledWith(
|
||||
"lifecycle_fallback_replaced_by_chat_final",
|
||||
expect.objectContaining({ runId: "run-1" })
|
||||
);
|
||||
} finally {
|
||||
metricSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores terminal chat events with same-or-lower payload sequence for a run", () => {
|
||||
const metricSpy = vi
|
||||
.spyOn(transcriptState, "logTranscriptDebugMetric")
|
||||
.mockImplementation(() => {});
|
||||
try {
|
||||
const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })];
|
||||
const dispatched: Array<Record<string, unknown>> = [];
|
||||
const dispatch = vi.fn((action) => {
|
||||
dispatched.push(action as never);
|
||||
});
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch,
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
seq: 4,
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: { role: "assistant", content: "final seq 4" },
|
||||
},
|
||||
});
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
seq: 4,
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: { role: "assistant", content: "final seq 4 replay" },
|
||||
},
|
||||
});
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
seq: 3,
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: { role: "assistant", content: "final seq 3 stale" },
|
||||
},
|
||||
});
|
||||
|
||||
const seed: AgentStoreSeed = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
};
|
||||
let state = agentStoreReducer(initialAgentStoreState, {
|
||||
type: "hydrateAgents",
|
||||
agents: [seed],
|
||||
});
|
||||
state = agentStoreReducer(state, {
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: {
|
||||
status: "running",
|
||||
runId: "run-1",
|
||||
runStartedAt: 900,
|
||||
},
|
||||
});
|
||||
for (const action of dispatched) {
|
||||
if (!action || typeof action !== "object") continue;
|
||||
if (typeof (action as { type?: unknown }).type !== "string") continue;
|
||||
state = agentStoreReducer(state, action as never);
|
||||
}
|
||||
const agentState = state.agents.find((entry) => entry.agentId === "agent-1");
|
||||
const assistantEntries = (agentState?.transcriptEntries ?? []).filter(
|
||||
(entry) => entry.kind === "assistant"
|
||||
);
|
||||
|
||||
expect(assistantEntries).toHaveLength(1);
|
||||
expect(assistantEntries[0]?.text).toBe("final seq 4");
|
||||
const staleCalls = metricSpy.mock.calls.filter(
|
||||
(call) => call[0] === "stale_terminal_chat_event_ignored"
|
||||
);
|
||||
expect(staleCalls).toHaveLength(2);
|
||||
expect(staleCalls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
runId: "run-1",
|
||||
seq: 4,
|
||||
lastTerminalSeq: 4,
|
||||
commitSource: "chat-final",
|
||||
})
|
||||
);
|
||||
expect(staleCalls[1]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
runId: "run-1",
|
||||
seq: 3,
|
||||
lastTerminalSeq: 4,
|
||||
commitSource: "chat-final",
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
metricSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts higher-sequence terminal chat events and keeps newest final text", () => {
|
||||
const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })];
|
||||
const dispatched: Array<Record<string, unknown>> = [];
|
||||
const dispatch = vi.fn((action) => {
|
||||
dispatched.push(action as never);
|
||||
});
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch,
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
seq: 2,
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: { role: "assistant", content: "final seq 2" },
|
||||
},
|
||||
});
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
seq: 3,
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: { role: "assistant", content: "final seq 3" },
|
||||
},
|
||||
});
|
||||
|
||||
const seed: AgentStoreSeed = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
};
|
||||
let state = agentStoreReducer(initialAgentStoreState, {
|
||||
type: "hydrateAgents",
|
||||
agents: [seed],
|
||||
});
|
||||
state = agentStoreReducer(state, {
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: {
|
||||
status: "running",
|
||||
runId: "run-1",
|
||||
runStartedAt: 900,
|
||||
},
|
||||
});
|
||||
for (const action of dispatched) {
|
||||
if (!action || typeof action !== "object") continue;
|
||||
if (typeof (action as { type?: unknown }).type !== "string") continue;
|
||||
state = agentStoreReducer(state, action as never);
|
||||
}
|
||||
const agentState = state.agents.find((entry) => entry.agentId === "agent-1");
|
||||
const assistantEntries = (agentState?.transcriptEntries ?? []).filter(
|
||||
(entry) => entry.kind === "assistant"
|
||||
);
|
||||
|
||||
expect(assistantEntries).toHaveLength(1);
|
||||
expect(assistantEntries[0]?.text).toBe("final seq 3");
|
||||
});
|
||||
|
||||
it("ignores terminal chat events for non-active runIds", () => {
|
||||
const agents = [
|
||||
createAgent({
|
||||
status: "running",
|
||||
runId: "run-2",
|
||||
runStartedAt: 900,
|
||||
streamText: "still streaming",
|
||||
thinkingTrace: "t",
|
||||
}),
|
||||
];
|
||||
const dispatched: Array<{ type: string; agentId: string; patch?: unknown }> = [];
|
||||
const dispatch = vi.fn((action) => {
|
||||
if (action && typeof action === "object") {
|
||||
dispatched.push(action as never);
|
||||
}
|
||||
});
|
||||
const requestHistoryRefresh = vi.fn(async () => {});
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch,
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh,
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: { role: "assistant", content: "old done" },
|
||||
},
|
||||
});
|
||||
|
||||
const terminalClears = dispatched.filter((entry) => {
|
||||
if (entry.type !== "updateAgent") return false;
|
||||
const patch = entry.patch as Record<string, unknown>;
|
||||
return patch.streamText === null || patch.thinkingTrace === null || patch.runStartedAt === null;
|
||||
});
|
||||
expect(terminalClears.length).toBe(0);
|
||||
expect(requestHistoryRefresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles aborted/error by appending output and clearing stream fields", () => {
|
||||
const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })];
|
||||
const dispatch = vi.fn();
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch,
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "aborted",
|
||||
message: { role: "assistant", content: "" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "appendOutput", agentId: "agent-1", line: "Run aborted." })
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: expect.objectContaining({ status: "idle" }),
|
||||
})
|
||||
);
|
||||
|
||||
const errorDispatch = vi.fn();
|
||||
const errorHandler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: errorDispatch,
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
errorHandler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "error",
|
||||
errorMessage: "bad",
|
||||
message: { role: "assistant", content: "" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(errorDispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "appendOutput", agentId: "agent-1", line: "Error: bad" })
|
||||
);
|
||||
expect(errorDispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: expect.objectContaining({ status: "error" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("suppresses aborted status line when abort is an approval pause", () => {
|
||||
const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })];
|
||||
const dispatch = vi.fn();
|
||||
const shouldSuppressRunAbortedLine = vi.fn(({ runId, stopReason }) => {
|
||||
return runId === "run-1" && stopReason === "rpc";
|
||||
});
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch,
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
shouldSuppressRunAbortedLine,
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "aborted",
|
||||
stopReason: "rpc",
|
||||
message: { role: "assistant", content: "" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(shouldSuppressRunAbortedLine).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
stopReason: "rpc",
|
||||
})
|
||||
);
|
||||
expect(dispatch).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "appendOutput", agentId: "agent-1", line: "Run aborted." })
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: expect.objectContaining({ status: "idle" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores late delta chat events after a run has already finalized", () => {
|
||||
const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })];
|
||||
const queueLivePatch = vi.fn();
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: vi.fn(),
|
||||
queueLivePatch,
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: { role: "assistant", content: "done" },
|
||||
},
|
||||
});
|
||||
|
||||
queueLivePatch.mockClear();
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "delta",
|
||||
message: { role: "assistant", content: "late text" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(queueLivePatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user