Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fd38e6deb |
@@ -1,204 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for actionable error rendering in useChatSocket (issue #1420).
|
||||
*
|
||||
* When a workspace agent returns an error on a canvas message/send, the canvas
|
||||
* should surface the actionable error_detail (e.g. oauth_org_not_allowed)
|
||||
* rather than the opaque "Agent error (Exception) — see workspace logs for details."
|
||||
* fallback. Falls back to summary, then a generic hint.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { useChatSocket, type UseChatSocketCallbacks } from "../hooks/useChatSocket";
|
||||
import { emitSocketEvent, _resetSocketEventListenersForTests } from "@/store/socket-events";
|
||||
import type { WSMessage } from "@/store/socket";
|
||||
|
||||
// Silence React StrictMode double-invoke noise.
|
||||
const WARN = console.warn;
|
||||
beforeEach(() => { console.warn = () => {}; });
|
||||
afterEach(() => { console.warn = WARN; });
|
||||
|
||||
beforeEach(() => {
|
||||
_resetSocketEventListenersForTests();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-18T10:00:00Z"));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
_resetSocketEventListenersForTests();
|
||||
});
|
||||
|
||||
const WORKSPACE_ID = "00000000-0000-0000-0000-000000000001";
|
||||
|
||||
function makeActivityErrorEvent(
|
||||
workspaceId: string,
|
||||
overrides: Partial<{
|
||||
error_detail: string;
|
||||
summary: string;
|
||||
method: string;
|
||||
status: string;
|
||||
}> = {},
|
||||
): WSMessage {
|
||||
const {
|
||||
error_detail = "",
|
||||
summary = "",
|
||||
method = "message/send",
|
||||
status = "error",
|
||||
} = overrides;
|
||||
return {
|
||||
event: "ACTIVITY_LOGGED",
|
||||
workspace_id: workspaceId,
|
||||
timestamp: "2026-05-18T10:00:00Z",
|
||||
payload: {
|
||||
activity_type: "a2a_receive",
|
||||
method,
|
||||
status,
|
||||
target_id: workspaceId,
|
||||
duration_ms: 500,
|
||||
summary,
|
||||
...(error_detail ? { error_detail } : {}),
|
||||
} as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useChatSocket actionable error rendering", () => {
|
||||
it("calls onSendError with error_detail when present in the payload", () => {
|
||||
const onSendError = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onSendError };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeActivityErrorEvent(WORKSPACE_ID, {
|
||||
error_detail: "403 Forbidden: oauth_org_not_allowed — Your organization has disabled Claude subscription access. Use an API key instead.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onSendError).toHaveBeenCalledTimes(1);
|
||||
expect(onSendError.mock.calls[0][0]).toContain("oauth_org_not_allowed");
|
||||
expect(onSendError.mock.calls[0][0]).not.toContain("workspace logs");
|
||||
expect(onSendError.mock.calls[0][0]).not.toContain("Agent error (Exception)");
|
||||
});
|
||||
|
||||
it("falls back to summary when error_detail is absent", () => {
|
||||
const onSendError = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onSendError };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeActivityErrorEvent(WORKSPACE_ID, {
|
||||
summary: "A2A request to ws-agent failed: connection refused",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onSendError).toHaveBeenCalledTimes(1);
|
||||
expect(onSendError.mock.calls[0][0]).toBe("A2A request to ws-agent failed: connection refused");
|
||||
});
|
||||
|
||||
it("falls back to generic hint when neither error_detail nor summary is present", () => {
|
||||
const onSendError = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onSendError };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(makeActivityErrorEvent(WORKSPACE_ID, {}));
|
||||
});
|
||||
|
||||
expect(onSendError).toHaveBeenCalledTimes(1);
|
||||
expect(onSendError.mock.calls[0][0]).toContain("Agent error");
|
||||
// Should NOT be the old opaque phrase
|
||||
expect(onSendError.mock.calls[0][0]).not.toContain("workspace logs");
|
||||
expect(onSendError.mock.calls[0][0]).not.toContain("Agent error (Exception)");
|
||||
});
|
||||
|
||||
it("does NOT call onSendError for other workspaces", () => {
|
||||
const onSendError = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onSendError };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeActivityErrorEvent("00000000-0000-0000-0000-000000000099", {
|
||||
error_detail: "some provider error",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onSendError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call onSendError for ok status", () => {
|
||||
const onSendError = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onSendError };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeActivityErrorEvent(WORKSPACE_ID, {
|
||||
status: "ok",
|
||||
error_detail: "this should not appear",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onSendError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call onSendError when error_detail is an empty string", () => {
|
||||
const onSendError = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onSendError };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeActivityErrorEvent(WORKSPACE_ID, {
|
||||
error_detail: "",
|
||||
summary: "",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// Empty strings are falsy — falls through to the generic hint
|
||||
expect(onSendError).toHaveBeenCalledTimes(1);
|
||||
expect(onSendError.mock.calls[0][0]).toContain("Agent error");
|
||||
expect(onSendError.mock.calls[0][0]).not.toContain("workspace logs");
|
||||
});
|
||||
|
||||
it("prefers error_detail over summary (error_detail is more actionable)", () => {
|
||||
const onSendError = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onSendError };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeActivityErrorEvent(WORKSPACE_ID, {
|
||||
error_detail: "403: api_key_expired",
|
||||
summary: "A2A request failed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onSendError).toHaveBeenCalledTimes(1);
|
||||
expect(onSendError.mock.calls[0][0]).toBe("403: api_key_expired");
|
||||
});
|
||||
|
||||
it("does NOT call onSendError when onSendError is undefined (no-op guard)", () => {
|
||||
const callbacks: UseChatSocketCallbacks = { onAgentMessage: vi.fn() };
|
||||
expect(() =>
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks)),
|
||||
).not.toThrow();
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeActivityErrorEvent(WORKSPACE_ID, {
|
||||
error_detail: "some error",
|
||||
}),
|
||||
);
|
||||
});
|
||||
// No error thrown even without onSendError
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for useChatSend — the canvas user→agent send hook.
|
||||
*
|
||||
* Behavioural focus: the poll-mode ("queued") path. When the target
|
||||
* workspace is an external / MCP-registered agent (delivery_mode=poll,
|
||||
* e.g. an operator laptop running the molecule MCP channel), the
|
||||
* platform's POST /workspaces/:id/a2a returns a synthetic
|
||||
* {status:"queued", delivery_mode:"poll"} envelope IMMEDIATELY with no
|
||||
* reply — the real reply arrives later over the AGENT_MESSAGE
|
||||
* WebSocket push.
|
||||
*
|
||||
* Pre-fix the hook treated that synthetic envelope as a terminal
|
||||
* response and called releaseSendGuards() → `sending` went false the
|
||||
* instant the POST returned → the "agent is working" indicator
|
||||
* vanished and the external turn looked dead. This suite pins the
|
||||
* fixed contract:
|
||||
*
|
||||
* - a real reply still clears `sending` (regression guard)
|
||||
* - a poll "queued" envelope KEEPS `sending` true (no terminal
|
||||
* clear) so the existing thinking indicator persists
|
||||
* - the eventual reply path (releaseSendGuards, the same call the
|
||||
* AGENT_MESSAGE WS push makes via useChatSocket) clears it
|
||||
* - an offline poll agent that never replies eventually surfaces an
|
||||
* honest error instead of an infinite spinner
|
||||
*
|
||||
* Plus pure-function coverage for the poll-envelope detector.
|
||||
*
|
||||
* Root cause: workspace-server a2a_proxy.go:402 poll-mode
|
||||
* short-circuit returns {status:"queued"} synchronously.
|
||||
*/
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from "vitest";
|
||||
import { act, renderHook, cleanup } from "@testing-library/react";
|
||||
|
||||
const { mockApiPost } = vi.hoisted(() => ({ mockApiPost: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { post: mockApiPost },
|
||||
}));
|
||||
|
||||
vi.mock("../uploads", () => ({
|
||||
uploadChatFiles: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import AFTER mocks.
|
||||
import {
|
||||
useChatSend,
|
||||
isPollQueuedResponse,
|
||||
extractReplyText,
|
||||
POLL_QUEUED_REPLY_TIMEOUT_MS,
|
||||
} from "../useChatSend";
|
||||
|
||||
const flush = () => act(async () => { await Promise.resolve(); });
|
||||
|
||||
describe("isPollQueuedResponse", () => {
|
||||
it("is true only for the synthetic poll-mode queued envelope", () => {
|
||||
expect(isPollQueuedResponse({ status: "queued", delivery_mode: "poll" })).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for a real agent reply", () => {
|
||||
expect(
|
||||
isPollQueuedResponse({ result: { parts: [{ kind: "text", text: "hi" }] } }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for null / undefined / partial shapes", () => {
|
||||
expect(isPollQueuedResponse(null)).toBe(false);
|
||||
expect(isPollQueuedResponse(undefined)).toBe(false);
|
||||
// status=queued without delivery_mode=poll is NOT the poll envelope
|
||||
// — don't accidentally swallow a real reply that happens to carry
|
||||
// an unrelated status field.
|
||||
expect(isPollQueuedResponse({ status: "queued" })).toBe(false);
|
||||
expect(isPollQueuedResponse({ delivery_mode: "poll" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractReplyText (regression guard — unchanged by fix)", () => {
|
||||
it("collects text parts from result", () => {
|
||||
expect(
|
||||
extractReplyText({ result: { parts: [{ kind: "text", text: "hello" }] } }),
|
||||
).toBe("hello");
|
||||
});
|
||||
it("returns empty for the poll-queued envelope", () => {
|
||||
expect(extractReplyText({ status: "queued", delivery_mode: "poll" })).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChatSend — poll-mode in-progress state", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockApiPost.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const setup = () => {
|
||||
const onUserMessage = vi.fn();
|
||||
const onAgentMessage = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useChatSend("ws-ext-1", {
|
||||
getHistoryMessages: () => [],
|
||||
onUserMessage,
|
||||
onAgentMessage,
|
||||
}),
|
||||
);
|
||||
return { result, onUserMessage, onAgentMessage };
|
||||
};
|
||||
|
||||
it("a real reply clears `sending` (regression guard)", async () => {
|
||||
mockApiPost.mockResolvedValue({
|
||||
result: { parts: [{ kind: "text", text: "real reply" }] },
|
||||
});
|
||||
const { result, onAgentMessage } = setup();
|
||||
|
||||
await act(async () => {
|
||||
void result.current.sendMessage("hi");
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(onAgentMessage).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.sending).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps `sending` true on a poll 'queued' envelope (no terminal clear)", async () => {
|
||||
mockApiPost.mockResolvedValue({ status: "queued", delivery_mode: "poll" });
|
||||
const { result, onAgentMessage } = setup();
|
||||
|
||||
await act(async () => {
|
||||
void result.current.sendMessage("hi external agent");
|
||||
});
|
||||
await flush();
|
||||
|
||||
// The POST resolved, but it was only a queued ack — the indicator
|
||||
// must stay up and no agent bubble should be rendered yet.
|
||||
expect(result.current.sending).toBe(true);
|
||||
expect(onAgentMessage).not.toHaveBeenCalled();
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("releaseSendGuards (the AGENT_MESSAGE-push path) clears the poll in-progress state", async () => {
|
||||
mockApiPost.mockResolvedValue({ status: "queued", delivery_mode: "poll" });
|
||||
const { result } = setup();
|
||||
|
||||
await act(async () => {
|
||||
void result.current.sendMessage("hi");
|
||||
});
|
||||
await flush();
|
||||
expect(result.current.sending).toBe(true);
|
||||
|
||||
// Simulate the terminal AGENT_MESSAGE WebSocket push arriving:
|
||||
// useChatSocket's onAgentMessage / onSendComplete call
|
||||
// releaseSendGuards. That must clear the in-progress state AND the
|
||||
// safety timer (asserted by the next test).
|
||||
act(() => {
|
||||
result.current.releaseSendGuards();
|
||||
});
|
||||
expect(result.current.sending).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces an honest error if a poll agent never replies (safety timeout)", async () => {
|
||||
mockApiPost.mockResolvedValue({ status: "queued", delivery_mode: "poll" });
|
||||
const { result } = setup();
|
||||
|
||||
await act(async () => {
|
||||
void result.current.sendMessage("hi");
|
||||
});
|
||||
await flush();
|
||||
expect(result.current.sending).toBe(true);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(POLL_QUEUED_REPLY_TIMEOUT_MS + 1000);
|
||||
});
|
||||
|
||||
expect(result.current.sending).toBe(false);
|
||||
expect(result.current.error).toMatch(/queued/i);
|
||||
});
|
||||
|
||||
it("does NOT fire the safety error when the reply arrives before timeout", async () => {
|
||||
mockApiPost.mockResolvedValue({ status: "queued", delivery_mode: "poll" });
|
||||
const { result } = setup();
|
||||
|
||||
await act(async () => {
|
||||
void result.current.sendMessage("hi");
|
||||
});
|
||||
await flush();
|
||||
|
||||
// Reply arrives (releaseSendGuards) well before the timeout.
|
||||
act(() => {
|
||||
result.current.releaseSendGuards();
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(POLL_QUEUED_REPLY_TIMEOUT_MS + 1000);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.sending).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { uploadChatFiles } from "../uploads";
|
||||
import { createMessage, type ChatMessage, type ChatAttachment } from "../types";
|
||||
@@ -22,8 +22,42 @@ interface A2AResponse {
|
||||
parts?: A2APart[];
|
||||
artifacts?: Array<{ parts: A2APart[] }>;
|
||||
};
|
||||
/** Synthetic poll-mode envelope. The platform returns this
|
||||
* immediately (HTTP 200) when the target workspace is registered
|
||||
* delivery_mode=poll — an external / MCP-registered agent with no
|
||||
* public URL (e.g. an operator's laptop running the molecule MCP
|
||||
* channel). The request has only been QUEUED into activity_logs;
|
||||
* the agent will pick it up on its next poll and the real reply
|
||||
* arrives asynchronously over the AGENT_MESSAGE WebSocket push
|
||||
* (consumed by useChatSocket). See workspace-server
|
||||
* a2a_proxy.go:402 (poll-mode short-circuit) and
|
||||
* a2a_proxy_helpers.go:516 (logA2AReceiveQueued). */
|
||||
status?: string;
|
||||
delivery_mode?: string;
|
||||
}
|
||||
|
||||
/** True when `resp` is the platform's synthetic poll-mode "queued"
|
||||
* envelope rather than a real agent reply. For these the send is
|
||||
* acknowledged-but-pending: the user's message landed and the agent
|
||||
* is working, but there is no reply yet — the terminal AGENT_MESSAGE
|
||||
* push will arrive later over the WebSocket. Treating this as a
|
||||
* terminal response (the pre-fix behaviour) cleared the "agent is
|
||||
* working" indicator the instant the POST returned, so an external
|
||||
* workspace turn looked dead even though work had not started. */
|
||||
export function isPollQueuedResponse(resp: A2AResponse | null | undefined): boolean {
|
||||
return !!resp && resp.status === "queued" && resp.delivery_mode === "poll";
|
||||
}
|
||||
|
||||
/** Hard ceiling on how long the "agent is working" indicator stays up
|
||||
* for a poll-mode turn with no reply. The terminal AGENT_MESSAGE push
|
||||
* normally clears it well before this. The cap exists so a poll-mode
|
||||
* workspace that is offline / never consumes its queue doesn't pin a
|
||||
* spinner forever — at which point we surface an honest, actionable
|
||||
* error instead of an opaque dead spinner. Generous because poll
|
||||
* agents (an operator laptop) can legitimately take minutes to wake,
|
||||
* poll, and respond; the goal is "eventually honest", not fail-fast. */
|
||||
export const POLL_QUEUED_REPLY_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
|
||||
export function extractReplyText(resp: A2AResponse): string {
|
||||
const collect = (parts: A2APart[] | undefined): string => {
|
||||
if (!parts) return "";
|
||||
@@ -59,14 +93,29 @@ export function useChatSend(workspaceId: string, options: UseChatSendOptions) {
|
||||
const sendInFlightRef = useRef(false);
|
||||
const sendingFromAPIRef = useRef(false);
|
||||
const sendTokenRef = useRef(0);
|
||||
// Safety-net timer armed only for poll-mode ("queued") turns: the
|
||||
// POST returns immediately with no reply, so the normal
|
||||
// POST-resolves-→-clear-spinner path can't drive the indicator. The
|
||||
// terminal AGENT_MESSAGE WebSocket push clears it via
|
||||
// releaseSendGuards (which also clears this timer); the timer is the
|
||||
// backstop for an offline poll agent that never consumes its queue.
|
||||
const pollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
|
||||
const clearPollTimeout = useCallback(() => {
|
||||
if (pollTimeoutRef.current !== null) {
|
||||
clearTimeout(pollTimeoutRef.current);
|
||||
pollTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const releaseSendGuards = useCallback(() => {
|
||||
clearPollTimeout();
|
||||
setSending(false);
|
||||
sendingFromAPIRef.current = false;
|
||||
sendInFlightRef.current = false;
|
||||
}, []);
|
||||
}, [clearPollTimeout]);
|
||||
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
@@ -146,6 +195,33 @@ export function useChatSend(workspaceId: string, options: UseChatSendOptions) {
|
||||
sendInFlightRef.current = false;
|
||||
return;
|
||||
}
|
||||
// Poll-mode ("queued") turn: the message landed and the
|
||||
// external/MCP agent will pick it up on its next poll, but
|
||||
// there is NO reply in this response. Pre-fix this fell
|
||||
// through to releaseSendGuards() below and the "agent is
|
||||
// working" indicator vanished the instant the POST returned —
|
||||
// an external-workspace turn looked dead even though work had
|
||||
// not started. Instead, keep `sending` true so the existing
|
||||
// thinking indicator (the same one internal agents use)
|
||||
// persists as a "received — agent is working" state; the
|
||||
// terminal AGENT_MESSAGE WebSocket push (consumed by
|
||||
// useChatSocket → onAgentMessage / onSendComplete →
|
||||
// releaseSendGuards) clears it when the real reply arrives,
|
||||
// exactly the path an internal async reply already uses.
|
||||
if (isPollQueuedResponse(resp)) {
|
||||
clearPollTimeout();
|
||||
pollTimeoutRef.current = setTimeout(() => {
|
||||
if (sendTokenRef.current !== myToken) return;
|
||||
if (!sendingFromAPIRef.current) return;
|
||||
releaseSendGuards();
|
||||
setError(
|
||||
"No response yet from this agent — it may be offline or " +
|
||||
"busy. Your message was delivered and is queued; the " +
|
||||
"reply will appear here if the agent picks it up.",
|
||||
);
|
||||
}, POLL_QUEUED_REPLY_TIMEOUT_MS);
|
||||
return;
|
||||
}
|
||||
const replyText = extractReplyText(resp);
|
||||
const replyFiles = extractFilesFromTask(
|
||||
(resp?.result ?? {}) as Record<string, unknown>,
|
||||
@@ -167,9 +243,15 @@ export function useChatSend(workspaceId: string, options: UseChatSendOptions) {
|
||||
setError("Failed to send message — agent may be unreachable");
|
||||
});
|
||||
},
|
||||
[workspaceId, sending, uploading],
|
||||
[workspaceId, sending, uploading, clearPollTimeout],
|
||||
);
|
||||
|
||||
// Drop the poll-mode safety timer on unmount / workspace switch so a
|
||||
// stale timeout can't fire setError against a panel the user has
|
||||
// already navigated away from. sendTokenRef guards correctness if it
|
||||
// ever did fire; this just avoids the wasted timer + setState churn.
|
||||
useEffect(() => clearPollTimeout, [clearPollTimeout]);
|
||||
|
||||
return {
|
||||
sending,
|
||||
uploading,
|
||||
|
||||
@@ -53,7 +53,6 @@ export function useChatSocket(
|
||||
const targetId = (p.target_id as string) || "";
|
||||
const durationMs = p.duration_ms as number | undefined;
|
||||
const summary = (p.summary as string) || "";
|
||||
const errorDetail = typeof p.error_detail === "string" ? p.error_detail : "";
|
||||
|
||||
let line = "";
|
||||
if (type === "a2a_receive" && method === "message/send") {
|
||||
@@ -68,14 +67,9 @@ export function useChatSocket(
|
||||
const own = (targetId || msg.workspace_id) === workspaceId;
|
||||
if (own) {
|
||||
callbacksRef.current.onSendComplete?.();
|
||||
// Prefer the actionable error_detail from the workspace agent
|
||||
// (e.g. "403 Forbidden: oauth_org_not_allowed ...") over the
|
||||
// opaque generic. Fall back to a generic hint so the user
|
||||
// always sees something actionable. Closes #1420.
|
||||
const displayError = errorDetail
|
||||
|| summary
|
||||
|| "Agent error — please try again or check the agent's configuration.";
|
||||
callbacksRef.current.onSendError?.(displayError);
|
||||
callbacksRef.current.onSendError?.(
|
||||
"Agent error (Exception) — see workspace logs for details.",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (type === "a2a_send") {
|
||||
|
||||
@@ -399,21 +399,7 @@ func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID stri
|
||||
// (no Do(), no maybeMarkContainerDead). The response is a synthetic
|
||||
// {status:"queued"} envelope so the caller (canvas, another workspace)
|
||||
// knows delivery is acknowledged but pending consumption.
|
||||
deliveryMode, deliveryModeErr := lookupDeliveryMode(ctx, workspaceID)
|
||||
if deliveryModeErr != nil {
|
||||
// internal#497 fail-closed: a real DB/context error on the
|
||||
// delivery-mode read MUST NOT silently fall through to the push
|
||||
// dispatch path — that is exactly what silently misrouted every
|
||||
// poll-mode peer for 5 days under the ce2db75f regression. Surface
|
||||
// a structured error so the delegation is marked failed (loud +
|
||||
// retryable) instead of dispatched to the wrong path.
|
||||
log.Printf("ProxyA2A: delivery-mode lookup failed for %s: %v — failing closed", workspaceID, deliveryModeErr)
|
||||
return 0, nil, &proxyA2AError{
|
||||
Status: http.StatusServiceUnavailable,
|
||||
Response: gin.H{"error": "delivery-mode lookup failed; refusing to dispatch to avoid silent misrouting"},
|
||||
}
|
||||
}
|
||||
if deliveryMode == models.DeliveryModePoll {
|
||||
if lookupDeliveryMode(ctx, workspaceID) == models.DeliveryModePoll {
|
||||
if logActivity {
|
||||
h.logA2AReceiveQueued(ctx, workspaceID, callerID, body, a2aMethod)
|
||||
}
|
||||
|
||||
@@ -468,64 +468,40 @@ func parseUsageFromA2AResponse(body []byte) (inputTokens, outputTokens int64) {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// lookupDeliveryMode returns the workspace's delivery_mode.
|
||||
//
|
||||
// internal#497 / RFC#497 fail-closed (SURGICAL scope): the *specific*
|
||||
// failure mode that hid the ce2db75f regression for 5 days is now
|
||||
// propagated instead of silently swallowed — a CONTEXT error
|
||||
// (context.Canceled / context.DeadlineExceeded). Under ce2db75f the
|
||||
// detached delegation goroutine ran on a cancelled request context, every
|
||||
// `SELECT delivery_mode` failed `context canceled`, this function returned
|
||||
// push, the poll-mode short-circuit in proxyA2ARequest was skipped, and
|
||||
// poll-mode peers (e.g. an operator laptop on molecule-mcp-claude-channel)
|
||||
// silently never got their a2a_receive inbox row. A transient,
|
||||
// systematic-once-triggered context cancellation became permanent
|
||||
// invisible misrouting. Returning that error lets the caller fail loud
|
||||
// (mark the delegation failed) instead of mis-dispatching.
|
||||
//
|
||||
// Scope is deliberately narrow: only ctx errors propagate. Other DB
|
||||
// errors retain the long-standing documented "fall back to push (today's
|
||||
// synchronous behavior)" contract — that path is loud + recoverable
|
||||
// (502 / SSRF reject / restart), unlike the silent poll-mode drop, and
|
||||
// the surrounding proxy (incl. the sibling checkWorkspaceBudget) is
|
||||
// intentionally built around that fail-open-to-push behavior. Widening
|
||||
// further is an RFC#497 follow-up, not part of this P0 fix.
|
||||
//
|
||||
// A genuinely *absent* configuration is NOT an error and still resolves to
|
||||
// push (the safe synchronous default): sql.ErrNoRows, a NULL/empty column,
|
||||
// or an unrecognised value all return (push, nil).
|
||||
// lookupDeliveryMode returns the workspace's delivery_mode. On any DB
|
||||
// error or missing row it returns DeliveryModePush — the fail-closed
|
||||
// default. "Closed" here means "fall back to today's behavior (synchronous
|
||||
// dispatch)" rather than "fall back to drop the request silently into
|
||||
// activity_logs where the agent might never see it." A poll-mode workspace
|
||||
// that briefly reads as push will get its A2A request dispatched to the
|
||||
// stored URL (or a 502 if no URL); a push-mode workspace that briefly
|
||||
// reads as poll would get its request silently queued with no dispatch.
|
||||
// The first failure is loud + recoverable; the second is silent.
|
||||
//
|
||||
// The function is intentionally lookup-only — it never mutates the row.
|
||||
// The register handler (registry.go) is the only writer for delivery_mode.
|
||||
//
|
||||
// See #2339 PR 1 for the column + register-flow side; this is the
|
||||
// proxy-side read used for the short-circuit in proxyA2ARequest.
|
||||
func lookupDeliveryMode(ctx context.Context, workspaceID string) (string, error) {
|
||||
func lookupDeliveryMode(ctx context.Context, workspaceID string) string {
|
||||
var mode sql.NullString
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT delivery_mode FROM workspaces WHERE id = $1`, workspaceID,
|
||||
).Scan(&mode)
|
||||
if err != nil {
|
||||
// internal#497: a context cancellation/deadline MUST NOT be
|
||||
// swallowed into a silent push default — that is the exact 5-day
|
||||
// silent-misrouting vector. Propagate so the caller fails closed.
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
log.Printf("ProxyA2A: lookupDeliveryMode(%s) context error (%v) — failing closed (NOT defaulting to push)", workspaceID, err)
|
||||
return "", err
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
log.Printf("ProxyA2A: lookupDeliveryMode(%s) failed (%v) — defaulting to push (non-ctx DB error; legacy fail-open-to-push contract)", workspaceID, err)
|
||||
log.Printf("ProxyA2A: lookupDeliveryMode(%s) failed (%v) — defaulting to push", workspaceID, err)
|
||||
}
|
||||
return models.DeliveryModePush, nil
|
||||
return models.DeliveryModePush
|
||||
}
|
||||
if !mode.Valid || mode.String == "" {
|
||||
return models.DeliveryModePush, nil
|
||||
return models.DeliveryModePush
|
||||
}
|
||||
if !models.IsValidDeliveryMode(mode.String) {
|
||||
log.Printf("ProxyA2A: workspace %s has invalid delivery_mode=%q — defaulting to push", workspaceID, mode.String)
|
||||
return models.DeliveryModePush, nil
|
||||
return models.DeliveryModePush
|
||||
}
|
||||
return mode.String, nil
|
||||
return mode.String
|
||||
}
|
||||
|
||||
// logA2AReceiveQueued records a poll-mode "queued" A2A receive into
|
||||
|
||||
@@ -2228,18 +2228,12 @@ func TestProxyA2A_PushMode_NoShortCircuit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyA2A_PollMode_FailsClosedToPush verifies the LEGACY safety
|
||||
// contract is PRESERVED for non-context DB errors: a generic DB error
|
||||
// reading delivery_mode still defaults to push (today's behavior), NOT
|
||||
// poll. Failing to push means a poll-mode workspace briefly attempts a
|
||||
// real dispatch — visible failure (502 / SSRF rejection / restart
|
||||
// cascade), not a silent drop into activity_logs where the agent might
|
||||
// never look. Loud > silent, recoverable > lost.
|
||||
//
|
||||
// internal#497 narrows the fail-closed change to *context* errors only
|
||||
// (the actual ce2db75f regression vector); generic DB errors keep this
|
||||
// long-standing fail-open-to-push contract. The ctx-error fail-closed is
|
||||
// covered by TestLookupDeliveryMode_ContextCanceled_FailsClosed.
|
||||
// TestProxyA2A_PollMode_FailsClosedToPush verifies the safety contract:
|
||||
// a DB error reading delivery_mode must default to push (the existing
|
||||
// behavior), NOT poll. Failing to push means a poll-mode workspace
|
||||
// briefly attempts a real dispatch — visible failure (502 / SSRF
|
||||
// rejection / restart cascade), not a silent drop into activity_logs
|
||||
// where the agent might never look. Loud > silent, recoverable > lost.
|
||||
func TestProxyA2A_PollMode_FailsClosedToPush(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t) // empty Redis — forces resolveAgentURL DB lookup
|
||||
@@ -2250,8 +2244,7 @@ func TestProxyA2A_PollMode_FailsClosedToPush(t *testing.T) {
|
||||
|
||||
expectBudgetCheck(mock, wsID)
|
||||
|
||||
// lookupDeliveryMode hits a generic (non-context) DB error → must
|
||||
// still default push (legacy contract preserved by internal#497).
|
||||
// lookupDeliveryMode hits a transient DB error → must default push.
|
||||
mock.ExpectQuery("SELECT delivery_mode FROM workspaces WHERE id").
|
||||
WithArgs(wsID).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
@@ -2275,7 +2268,7 @@ func TestProxyA2A_PollMode_FailsClosedToPush(t *testing.T) {
|
||||
var resp map[string]interface{}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["status"] == "queued" {
|
||||
t.Errorf("generic DB error on delivery_mode lookup silently queued the request — must fail-open-to-push, got body: %s", w.Body.String())
|
||||
t.Errorf("DB error on delivery_mode lookup silently queued the request — must fail-closed-to-push, got body: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2284,37 +2277,6 @@ func TestProxyA2A_PollMode_FailsClosedToPush(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLookupDeliveryMode_ContextCanceled_FailsClosed is the internal#497
|
||||
// regression test for the SECONDARY defect. It pins the exact invariant
|
||||
// that hid the ce2db75f regression for 5 days: when the delivery_mode read
|
||||
// fails because the context was cancelled (precisely what happened in the
|
||||
// detached delegation goroutine running on a returned request context),
|
||||
// lookupDeliveryMode MUST return an error and MUST NOT silently return
|
||||
// "push". Returning push there is what skipped the poll-mode short-circuit
|
||||
// and silently dropped 100% of poll-mode peer deliveries.
|
||||
//
|
||||
// A pre-cancelled context makes QueryRowContext fail with
|
||||
// context.Canceled deterministically — no DB rows are mocked because the
|
||||
// query never reaches a result.
|
||||
func TestLookupDeliveryMode_ContextCanceled_FailsClosed(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
// The query fails on the cancelled ctx before matching; provide a
|
||||
// permissive expectation so sqlmock doesn't complain about the attempt.
|
||||
mock.ExpectQuery("SELECT delivery_mode FROM workspaces WHERE id").
|
||||
WillReturnError(context.Canceled)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // simulate the HTTP handler having returned (request ctx dead)
|
||||
|
||||
mode, err := lookupDeliveryMode(ctx, "ws-poll-peer")
|
||||
if err == nil {
|
||||
t.Fatalf("internal#497 regression: lookupDeliveryMode swallowed a context error and returned mode=%q with nil err — this is the exact 5-day silent-misrouting vector", mode)
|
||||
}
|
||||
if mode == models.DeliveryModePush {
|
||||
t.Errorf("internal#497 regression: context error must NOT default to push (got mode=%q)", mode)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== a2aClient ResponseHeaderTimeout config ====================
|
||||
|
||||
func TestA2AClientResponseHeaderTimeout(t *testing.T) {
|
||||
|
||||
@@ -672,13 +672,6 @@ func logActivityExec(ctx context.Context, exec activityExecutor, broadcaster eve
|
||||
if len(params.ToolTrace) > 0 {
|
||||
payload["tool_trace"] = json.RawMessage(params.ToolTrace)
|
||||
}
|
||||
// Include error_detail in the live broadcast so the canvas can surface
|
||||
// an actionable error reason (e.g. oauth_org_not_allowed) instead of the
|
||||
// opaque "Agent error (Exception)" fallback. The runtime's
|
||||
// report_activity helper caps this at 4096 chars.
|
||||
if params.ErrorDetail != nil {
|
||||
payload["error_detail"] = *params.ErrorDetail
|
||||
}
|
||||
// Include request/response bodies in the live broadcast so the
|
||||
// canvas's Agent Comms panel can render the actual task text
|
||||
// and reply text immediately, instead of falling back to the
|
||||
|
||||
@@ -934,93 +934,6 @@ func TestLogActivity_Broadcast_IncludesRequestAndResponseBodies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogActivity_Broadcast_IncludesErrorDetail pins the fix for #1420:
|
||||
// error_detail was stored in the DB but never included in the live
|
||||
// ACTIVITY_LOGGED WebSocket broadcast, so the canvas could only show
|
||||
// "Agent error (Exception) — see workspace logs for details." without
|
||||
// surfacing the actionable error reason (e.g. oauth_org_not_allowed).
|
||||
func TestLogActivity_Broadcast_IncludesErrorDetail(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
defer mock.ExpectationsWereMet()
|
||||
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
cb := &recordingBroadcaster{}
|
||||
srcID := "ws-canvas"
|
||||
tgtID := "ws-agent"
|
||||
method := "message/send"
|
||||
summary := "A2A request to ws-agent failed"
|
||||
errorDetail := "403 Forbidden: oauth_org_not_allowed — Your organization has disabled Claude subscription access. Use an API key or ask your admin to enable access."
|
||||
status := "error"
|
||||
|
||||
LogActivity(context.Background(), cb, ActivityParams{
|
||||
WorkspaceID: srcID,
|
||||
ActivityType: "a2a_receive",
|
||||
SourceID: &srcID,
|
||||
TargetID: &tgtID,
|
||||
Method: &method,
|
||||
Summary: &summary,
|
||||
Status: status,
|
||||
ErrorDetail: &errorDetail,
|
||||
})
|
||||
|
||||
if len(cb.calls) != 1 {
|
||||
t.Fatalf("expected 1 broadcast, got %d", len(cb.calls))
|
||||
}
|
||||
payload := cb.calls[0].payload
|
||||
if payload["activity_type"] != "a2a_receive" {
|
||||
t.Errorf("activity_type = %v, want a2a_receive", payload["activity_type"])
|
||||
}
|
||||
ed, ok := payload["error_detail"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("error_detail missing from broadcast payload: got %#v", payload["error_detail"])
|
||||
}
|
||||
if ed != errorDetail {
|
||||
t.Errorf("error_detail = %q, want %q", ed, errorDetail)
|
||||
}
|
||||
if payload["status"] != status {
|
||||
t.Errorf("status = %v, want %q", payload["status"], status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogActivity_Broadcast_OmitsNilErrorDetail verifies that when
|
||||
// ErrorDetail is nil the broadcast does not include an empty error_detail key
|
||||
// (matching the same omission pattern as request_body/response_body above).
|
||||
func TestLogActivity_Broadcast_OmitsNilErrorDetail(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
defer mock.ExpectationsWereMet()
|
||||
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
cb := &recordingBroadcaster{}
|
||||
srcID := "ws-canvas"
|
||||
tgtID := "ws-agent"
|
||||
method := "message/send"
|
||||
summary := "A2A request succeeded"
|
||||
status := "ok"
|
||||
|
||||
LogActivity(context.Background(), cb, ActivityParams{
|
||||
WorkspaceID: srcID,
|
||||
ActivityType: "a2a_receive",
|
||||
SourceID: &srcID,
|
||||
TargetID: &tgtID,
|
||||
Method: &method,
|
||||
Summary: &summary,
|
||||
Status: status,
|
||||
// ErrorDetail intentionally omitted (nil)
|
||||
})
|
||||
|
||||
if len(cb.calls) != 1 {
|
||||
t.Fatalf("expected 1 broadcast, got %d", len(cb.calls))
|
||||
}
|
||||
payload := cb.calls[0].payload
|
||||
if _, present := payload["error_detail"]; present {
|
||||
t.Errorf("error_detail should be omitted when nil, got %v", payload["error_detail"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogActivityTx_DefersBroadcastUntilCommitHook pins the #149
|
||||
// contract: LogActivityTx returns a commitHook that the caller MUST
|
||||
// invoke after tx.Commit(); the broadcast MUST NOT fire from inside
|
||||
|
||||
@@ -162,32 +162,8 @@ func (h *DelegationHandler) Delegate(c *gin.Context) {
|
||||
},
|
||||
})
|
||||
|
||||
// Fire-and-forget: send A2A in a background goroutine.
|
||||
//
|
||||
// internal#497 — the goroutine MUST NOT inherit the HTTP request's
|
||||
// cancellation. `ctx` here is c.Request.Context(); the handler returns
|
||||
// 202 a few lines below, which cancels that context immediately. Before
|
||||
// this fix (regression ce2db75f) executeDelegation ran on the
|
||||
// request-scoped ctx, so every DB op + proxy call in the detached
|
||||
// goroutine failed `context canceled` the instant the 202 was written.
|
||||
// That silently broke 100% of A2A peer delegations fleet-wide since
|
||||
// 2026-05-12 (poll-mode peers never got their a2a_receive inbox row;
|
||||
// lookupDeliveryMode swallowed the ctx error and defaulted to push).
|
||||
//
|
||||
// context.WithoutCancel detaches cancellation/deadline while PRESERVING
|
||||
// all context values (trace/correlation/tenant ids that proxyA2ARequest
|
||||
// and the broadcaster read off ctx) — this is the established pattern in
|
||||
// this package (a2a_proxy.go:850, a2a_proxy_helpers.go:525,
|
||||
// registry.go:822). The 30-minute ceiling matches the prior internal
|
||||
// budget executeDelegation used before ce2db75f and the proxy's own
|
||||
// absolute agent-dispatch ceiling (a2a_proxy.go forwardCtx).
|
||||
delegationCtx, cancelDelegation := context.WithTimeout(
|
||||
context.WithoutCancel(ctx), 30*time.Minute,
|
||||
)
|
||||
go func() {
|
||||
defer cancelDelegation()
|
||||
h.executeDelegation(delegationCtx, sourceID, body.TargetID, delegationID, a2aBody)
|
||||
}()
|
||||
// Fire-and-forget: send A2A in background goroutine
|
||||
go h.executeDelegation(ctx, sourceID, body.TargetID, delegationID, a2aBody)
|
||||
|
||||
// Broadcast event so canvas shows delegation in real-time
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationSent), sourceID, map[string]interface{}{
|
||||
|
||||
@@ -16,65 +16,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ---------- internal#497 regression: detached goroutine ctx must outlive the handler ----------
|
||||
|
||||
// TestDelegate_DetachedContext_SurvivesRequestCancellation pins the
|
||||
// load-bearing invariant that regression ce2db75f violated: the context
|
||||
// handed to executeDelegation in the fire-and-forget goroutine must NOT be
|
||||
// cancelled when the HTTP handler returns 202 (which cancels
|
||||
// c.Request.Context()). Before the fix, executeDelegation ran on the
|
||||
// request-scoped ctx, so every DB op + proxy call failed `context
|
||||
// canceled` the instant the 202 was written — silently breaking 100% of
|
||||
// A2A peer delegations fleet-wide since 2026-05-12.
|
||||
//
|
||||
// This test asserts the exact ctx-derivation contract used by Delegate
|
||||
// (context.WithoutCancel(parent) + a timeout budget): the derived context
|
||||
// (a) stays alive after the parent is cancelled, and (b) still carries
|
||||
// parent values (trace/correlation/tenant ids the downstream proxy +
|
||||
// broadcaster read off ctx). It is intentionally DB-free and fast.
|
||||
func TestDelegate_DetachedContext_SurvivesRequestCancellation(t *testing.T) {
|
||||
type ctxKey string
|
||||
const traceKey ctxKey = "trace-id"
|
||||
|
||||
// Simulate c.Request.Context() carrying a correlation value.
|
||||
parent, cancelParent := context.WithCancel(
|
||||
context.WithValue(context.Background(), traceKey, "trace-abc-123"),
|
||||
)
|
||||
|
||||
// Exact derivation Delegate uses for the detached goroutine.
|
||||
delegationCtx, cancelDelegation := context.WithTimeout(
|
||||
context.WithoutCancel(parent), 30*time.Minute,
|
||||
)
|
||||
defer cancelDelegation()
|
||||
|
||||
// The HTTP handler "returns 202" → request context is cancelled.
|
||||
cancelParent()
|
||||
|
||||
if err := parent.Err(); err == nil {
|
||||
t.Fatal("precondition: parent context should be cancelled after the handler returns")
|
||||
}
|
||||
|
||||
// (a) Cancellation MUST NOT propagate to the detached context.
|
||||
select {
|
||||
case <-delegationCtx.Done():
|
||||
t.Fatalf("regression: detached delegation ctx was cancelled by the handler returning (err=%v) — executeDelegation would fail every DB op with `context canceled`", delegationCtx.Err())
|
||||
default:
|
||||
// alive — correct
|
||||
}
|
||||
|
||||
// (b) Parent values MUST still be readable (WithoutCancel preserves
|
||||
// values; trace/correlation/tenant ids the proxy + broadcaster use).
|
||||
if got, _ := delegationCtx.Value(traceKey).(string); got != "trace-abc-123" {
|
||||
t.Errorf("detached ctx lost the parent trace value: got %q, want %q", got, "trace-abc-123")
|
||||
}
|
||||
|
||||
// And it still has a real deadline (the 30m budget), so it is not an
|
||||
// unbounded background context.
|
||||
if _, hasDeadline := delegationCtx.Deadline(); !hasDeadline {
|
||||
t.Error("detached ctx must carry the 30-minute timeout budget, but has no deadline")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Delegate: missing target_id → 400 ----------
|
||||
|
||||
func TestDelegate_MissingTargetID(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user