4fa4f13558
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
666 lines
20 KiB
TypeScript
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);
|
|
});
|
|
});
|