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 => { 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 }) => { 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); }); });