Files
claw3d/tests/unit/chatSendOperation.test.ts
Luke The Dev 4fa4f13558 First Release of Claw3D (#11)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
2026-03-19 23:14:04 -05:00

666 lines
20 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { AgentState } from "@/features/agents/state/store";
import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation";
import { GatewayResponseError } from "@/lib/gateway/errors";
import { formatMetaMarkdown } from "@/lib/text/message-extract";
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: false,
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,
};
};
const createWebchatBlockedPatchError = () =>
new GatewayResponseError({
code: "INVALID_REQUEST",
message: "webchat clients cannot patch sessions; use chat.send for session-scoped updates",
});
describe("sendChatMessageViaStudio", () => {
it("handles_reset_command", async () => {
const agent = createAgent({
outputLines: ["old"],
streamText: "stream",
thinkingTrace: "thinking",
lastResult: "result",
sessionSettingsSynced: true,
});
const dispatch = vi.fn();
const call = vi.fn(async () => ({}));
const clearRunTracking = vi.fn();
await sendChatMessageViaStudio({
client: { call },
dispatch,
getAgent: () => agent,
agentId: agent.agentId,
sessionKey: agent.sessionKey,
message: "/reset",
clearRunTracking,
now: () => 1234,
generateRunId: () => "run-1",
});
expect(clearRunTracking).toHaveBeenCalledWith("run-1");
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: "updateAgent",
agentId: agent.agentId,
patch: expect.objectContaining({
outputLines: [],
streamText: null,
thinkingTrace: null,
lastResult: null,
transcriptEntries: [],
}),
})
);
});
it("syncs_session_settings_when_not_synced", async () => {
const agent = createAgent({ sessionSettingsSynced: false, sessionCreated: false });
const dispatch = vi.fn();
const call = vi.fn(async (method: string) => {
if (method === "sessions.patch") {
return {
ok: true,
key: agent.sessionKey,
entry: { thinkingLevel: "medium" },
resolved: { modelProvider: "openai", model: "gpt-5" },
};
}
return { ok: true };
});
await sendChatMessageViaStudio({
client: { call },
dispatch,
getAgent: () => agent,
agentId: agent.agentId,
sessionKey: agent.sessionKey,
message: "hello",
now: () => 1234,
generateRunId: () => "run-1",
});
const methods = call.mock.calls.map((entry) => entry[0]);
expect(methods).toEqual(["sessions.patch", "chat.send"]);
expect(call).toHaveBeenCalledWith(
"sessions.patch",
expect.objectContaining({
key: agent.sessionKey,
model: "openai/gpt-5",
thinkingLevel: "medium",
})
);
expect(dispatch).toHaveBeenCalledWith({
type: "updateAgent",
agentId: agent.agentId,
patch: { sessionSettingsSynced: true, sessionCreated: true },
});
});
it("continues_send_when_webchat_patch_is_blocked", async () => {
const agent = createAgent({ sessionSettingsSynced: false, sessionCreated: false });
const dispatch = vi.fn();
const call = vi.fn(async (method: string, payload?: unknown) => {
if (method === "sessions.patch") {
throw createWebchatBlockedPatchError();
}
if (method === "chat.send") {
const runId =
payload &&
typeof payload === "object" &&
"idempotencyKey" in payload &&
typeof payload.idempotencyKey === "string"
? payload.idempotencyKey
: "run";
return { runId, status: "started" };
}
return { ok: true };
});
await sendChatMessageViaStudio({
client: { call },
dispatch,
getAgent: () => agent,
agentId: agent.agentId,
sessionKey: agent.sessionKey,
message: "hello",
now: () => 1234,
generateRunId: () => "run-1",
});
const methods = call.mock.calls.map((entry) => entry[0]);
expect(methods).toEqual(["sessions.patch", "chat.send"]);
expect(dispatch).toHaveBeenCalledWith({
type: "updateAgent",
agentId: agent.agentId,
patch: { sessionSettingsSynced: true, sessionCreated: true },
});
const errorLines = dispatch.mock.calls
.map((entry) => entry[0])
.filter(
(
action
): action is {
type: "appendOutput";
line: string;
} =>
action &&
typeof action === "object" &&
"type" in action &&
action.type === "appendOutput" &&
"line" in action &&
typeof action.line === "string" &&
action.line.startsWith("Error:")
)
.map((action) => action.line);
expect(errorLines).toEqual([]);
});
it("fails_send_when_patch_error_is_not_webchat_blocked", async () => {
const agent = createAgent({ sessionSettingsSynced: false, sessionCreated: false });
const dispatch = vi.fn();
const call = vi.fn(async (method: string) => {
if (method === "sessions.patch") {
throw new GatewayResponseError({
code: "INVALID_REQUEST",
message: "invalid model ref",
});
}
return { ok: true };
});
await sendChatMessageViaStudio({
client: { call },
dispatch,
getAgent: () => agent,
agentId: agent.agentId,
sessionKey: agent.sessionKey,
message: "hello",
now: () => 1234,
generateRunId: () => "run-1",
});
const methods = call.mock.calls.map((entry) => entry[0]);
expect(methods).toEqual(["sessions.patch"]);
expect(dispatch).toHaveBeenCalledWith({
type: "appendOutput",
agentId: agent.agentId,
line: "Error: invalid model ref",
});
});
it("suppresses_patch_retry_after_webchat_blocked_patch_error", async () => {
let agent = createAgent({ sessionSettingsSynced: false, sessionCreated: false });
const dispatch = vi.fn(
(action: { type: string; agentId?: string; patch?: Partial<AgentState> }) => {
if (action.type !== "updateAgent" || action.agentId !== agent.agentId || !action.patch) {
return;
}
agent = { ...agent, ...action.patch };
}
);
const call = vi.fn(async (method: string, payload?: unknown) => {
if (method === "sessions.patch") {
throw createWebchatBlockedPatchError();
}
if (method === "chat.send") {
const runId =
payload &&
typeof payload === "object" &&
"idempotencyKey" in payload &&
typeof payload.idempotencyKey === "string"
? payload.idempotencyKey
: "run";
return { runId, status: "started" };
}
return { ok: true };
});
await sendChatMessageViaStudio({
client: { call },
dispatch,
getAgent: () => agent,
agentId: agent.agentId,
sessionKey: agent.sessionKey,
message: "first",
now: () => 1234,
generateRunId: () => "run-1",
});
await sendChatMessageViaStudio({
client: { call },
dispatch,
getAgent: () => agent,
agentId: agent.agentId,
sessionKey: agent.sessionKey,
message: "second",
now: () => 1240,
generateRunId: () => "run-2",
});
const methods = call.mock.calls.map((entry) => entry[0]);
expect(methods.filter((method) => method === "sessions.patch")).toHaveLength(1);
expect(methods.filter((method) => method === "chat.send")).toHaveLength(2);
expect(agent.sessionSettingsSynced).toBe(true);
expect(agent.sessionCreated).toBe(true);
});
it("syncs exec session overrides for ask-first agents", async () => {
const agent = createAgent({
sessionSettingsSynced: false,
sessionCreated: false,
sessionExecHost: "gateway",
sessionExecSecurity: "allowlist",
sessionExecAsk: "always",
});
const dispatch = vi.fn();
const call = vi.fn(async (method: string) => {
if (method === "sessions.patch") {
return {
ok: true,
key: agent.sessionKey,
entry: { thinkingLevel: "medium" },
resolved: { modelProvider: "openai", model: "gpt-5" },
};
}
return { ok: true };
});
await sendChatMessageViaStudio({
client: { call },
dispatch,
getAgent: () => agent,
agentId: agent.agentId,
sessionKey: agent.sessionKey,
message: "hello",
now: () => 1234,
generateRunId: () => "run-1",
});
expect(call).toHaveBeenCalledWith(
"sessions.patch",
expect.objectContaining({
key: agent.sessionKey,
execHost: "gateway",
execSecurity: "allowlist",
execAsk: "always",
})
);
});
it("does_not_sync_session_settings_when_already_synced", async () => {
const agent = createAgent({ sessionSettingsSynced: true });
const dispatch = vi.fn();
const call = vi.fn(async () => ({ runId: "run-1", status: "started" }));
await sendChatMessageViaStudio({
client: { call },
dispatch,
getAgent: () => agent,
agentId: agent.agentId,
sessionKey: agent.sessionKey,
message: "hello",
now: () => 1234,
generateRunId: () => "run-1",
});
expect(call).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({ sessionKey: agent.sessionKey })
);
expect(call).not.toHaveBeenCalledWith(
"sessions.patch",
expect.anything()
);
});
it("clears_running_state_for_unknown_success_payload_shape", async () => {
const agent = createAgent({ sessionSettingsSynced: true, sessionCreated: true });
const dispatch = vi.fn();
const call = vi.fn(async () => ({ ok: true }));
await sendChatMessageViaStudio({
client: { call },
dispatch,
getAgent: () => agent,
agentId: agent.agentId,
sessionKey: agent.sessionKey,
message: "hello",
now: () => 1234,
generateRunId: () => "run-1",
});
const idlePatchAction = dispatch.mock.calls
.map((entry) => entry[0])
.find(
(action) =>
action?.type === "updateAgent" &&
action?.agentId === agent.agentId &&
action?.patch?.status === "idle" &&
action?.patch?.runId === null
);
expect(idlePatchAction).toBeTruthy();
});
it("clears_running_state_for_stop_style_immediate_success_payload", async () => {
const agent = createAgent({ sessionSettingsSynced: true, sessionCreated: true });
const dispatch = vi.fn();
const call = vi.fn(async () => ({ ok: true, aborted: false, runIds: [] }));
await sendChatMessageViaStudio({
client: { call },
dispatch,
getAgent: () => agent,
agentId: agent.agentId,
sessionKey: agent.sessionKey,
message: "stop please",
now: () => 1234,
generateRunId: () => "run-1",
});
const idlePatchAction = dispatch.mock.calls
.map((entry) => entry[0])
.find(
(action) =>
action?.type === "updateAgent" &&
action?.agentId === agent.agentId &&
action?.patch?.status === "idle" &&
action?.patch?.runId === null &&
action?.patch?.runStartedAt === null &&
action?.patch?.streamText === null &&
action?.patch?.thinkingTrace === null
);
expect(idlePatchAction).toBeTruthy();
});
it("keeps_running_state_for_matching_streaming_status_payloads", async () => {
const payloads = [{ runId: "run-1", status: "started" }, { runId: "run-1", status: "in_flight" }];
for (const payload of payloads) {
const agent = createAgent({ sessionSettingsSynced: true, sessionCreated: true });
const dispatch = vi.fn();
const call = vi.fn(async () => payload);
await sendChatMessageViaStudio({
client: { call },
dispatch,
getAgent: () => agent,
agentId: agent.agentId,
sessionKey: agent.sessionKey,
message: "hello",
now: () => 1234,
generateRunId: () => "run-1",
});
const idlePatchAction = dispatch.mock.calls
.map((entry) => entry[0])
.find(
(action) =>
action?.type === "updateAgent" &&
action?.agentId === agent.agentId &&
action?.patch?.status === "idle"
);
expect(idlePatchAction).toBeUndefined();
}
});
it("clears_running_state_for_streaming_shape_with_mismatched_run_id", async () => {
const agent = createAgent({ sessionSettingsSynced: true, sessionCreated: true });
const dispatch = vi.fn();
const call = vi.fn(async () => ({ runId: "different-run", status: "started" }));
await sendChatMessageViaStudio({
client: { call },
dispatch,
getAgent: () => agent,
agentId: agent.agentId,
sessionKey: agent.sessionKey,
message: "hello",
now: () => 1234,
generateRunId: () => "run-1",
});
const idlePatchAction = dispatch.mock.calls
.map((entry) => entry[0])
.find(
(action) =>
action?.type === "updateAgent" &&
action?.agentId === agent.agentId &&
action?.patch?.status === "idle" &&
action?.patch?.runId === null
);
expect(idlePatchAction).toBeTruthy();
});
it("supports_internal_send_without_local_user_echo", async () => {
const agent = createAgent({ sessionSettingsSynced: true });
const dispatch = vi.fn();
const call = vi.fn(async () => ({ ok: true }));
await sendChatMessageViaStudio({
client: { call },
dispatch,
getAgent: () => agent,
agentId: agent.agentId,
sessionKey: agent.sessionKey,
message: "internal follow-up",
echoUserMessage: false,
now: () => 1234,
generateRunId: () => "run-1",
});
const dispatchedActions = dispatch.mock.calls.map((entry) => entry[0]);
expect(
dispatchedActions.some(
(action) => action.type === "appendOutput" && action.line === "> internal follow-up"
)
).toBe(false);
const runningUpdate = dispatchedActions.find(
(action) => action.type === "updateAgent" && action.patch?.status === "running"
);
expect(runningUpdate).toBeTruthy();
if (runningUpdate && runningUpdate.type === "updateAgent") {
expect(runningUpdate.patch.lastUserMessage).toBeUndefined();
}
});
it("marks_error_on_gateway_failure", async () => {
const agent = createAgent({ sessionSettingsSynced: true });
const dispatch = vi.fn();
const call = vi.fn(async (method: string) => {
if (method === "chat.send") {
throw new Error("boom");
}
return { ok: true };
});
await sendChatMessageViaStudio({
client: { call },
dispatch,
getAgent: () => agent,
agentId: agent.agentId,
sessionKey: agent.sessionKey,
message: "hello",
now: () => 1234,
generateRunId: () => "run-1",
});
expect(dispatch).toHaveBeenCalledWith({
type: "updateAgent",
agentId: agent.agentId,
patch: { status: "error", runId: null, runStartedAt: null, streamText: null, thinkingTrace: null },
});
expect(dispatch).toHaveBeenCalledWith({
type: "appendOutput",
agentId: agent.agentId,
line: "Error: boom",
});
});
it("optimistically_appends_only_user_content_line", async () => {
const agent = createAgent({ sessionSettingsSynced: true });
const dispatch = vi.fn();
const call = vi.fn(async () => ({ ok: true }));
await sendChatMessageViaStudio({
client: { call },
dispatch,
getAgent: () => agent,
agentId: agent.agentId,
sessionKey: agent.sessionKey,
message: "Hello world",
now: () => 1234,
generateRunId: () => "run-1",
});
const appendLines = dispatch.mock.calls
.map((entry) => entry[0])
.filter((action): action is { type: "appendOutput"; line: string } => {
return Boolean(
action &&
typeof action === "object" &&
"type" in action &&
action.type === "appendOutput" &&
"line" in action &&
typeof action.line === "string"
);
})
.map((action) => action.line);
expect(appendLines).toContain("> Hello world");
expect(appendLines.some((line) => line.startsWith("[[meta]]"))).toBe(false);
});
it("uses_monotonic_timestamp_for_optimistic_user_turn_ordering", async () => {
const sessionKey = "agent:agent-1:studio:test-session";
const agent = createAgent({
sessionSettingsSynced: true,
transcriptEntries: [
{
entryId: "history:assistant:1",
role: "assistant",
kind: "assistant",
text: "previous assistant",
sessionKey,
runId: null,
source: "history",
timestampMs: 5000,
sequenceKey: 10,
confirmed: true,
fingerprint: "fp-prev-assistant",
},
],
});
const dispatch = vi.fn();
const call = vi.fn(async () => ({ ok: true }));
await sendChatMessageViaStudio({
client: { call },
dispatch,
getAgent: () => agent,
agentId: agent.agentId,
sessionKey: agent.sessionKey,
message: "new message",
now: () => 1000,
generateRunId: () => "run-1",
});
const optimisticUserAppend = dispatch.mock.calls
.map((entry) => entry[0])
.find(
(action) =>
action &&
typeof action === "object" &&
"type" in action &&
action.type === "appendOutput" &&
"line" in action &&
action.line === "> new message"
);
expect(optimisticUserAppend).toBeTruthy();
expect((optimisticUserAppend as { transcript?: { timestampMs?: number } }).transcript?.timestampMs).toBe(5001);
});
it("uses_output_meta_timestamps_when_transcript_entries_are_missing", async () => {
const sessionKey = "agent:agent-1:studio:test-session";
const agent = createAgent({
sessionSettingsSynced: true,
transcriptEntries: undefined,
outputLines: [
formatMetaMarkdown({ role: "assistant", timestamp: 12_000 }),
"previous assistant",
],
});
const dispatch = vi.fn();
const call = vi.fn(async () => ({ ok: true }));
await sendChatMessageViaStudio({
client: { call },
dispatch,
getAgent: () => agent,
agentId: agent.agentId,
sessionKey,
message: "new message",
now: () => 1000,
generateRunId: () => "run-1",
});
const optimisticUserAppend = dispatch.mock.calls
.map((entry) => entry[0])
.find(
(action) =>
action &&
typeof action === "object" &&
"type" in action &&
action.type === "appendOutput" &&
"line" in action &&
action.line === "> new message"
);
expect(optimisticUserAppend).toBeTruthy();
expect((optimisticUserAppend as { transcript?: { timestampMs?: number } }).transcript?.timestampMs).toBe(12_001);
});
});