a4e0fc0452
* feat(orchestrator): carry sender_task metadata through user message paths Add WithSenderTask builder to UserMessageMeta and a metadata-aware QueueMessageWithMetadata variant on the message queue. The drain path in event_handlers_agent merges queued metadata into the recorded user message so attribution survives the queue → DB transition. * feat(mcp): attribute message_task_kandev calls to the sending agent The MCP server inside agentctl now injects sender_task_id and sender_session_id from its own server context. The backend handler validates the sender (rejecting missing IDs, self-messages, and unknown tasks), looks up the sender title, wraps the prompt in a <kandev-system> attribution block, and propagates structured metadata across all three delivery paths (queue, created-session, record+prompt). The wrapped content lets the receiving agent identify the peer both live and on ACP session resume; the existing sysprompt strip pipeline hides the wrapper from the UI. * feat(web): render sender-task badge on cross-task agent messages User messages whose metadata carries sender_task_id now render a clickable purple badge above the bubble, linking to the source task. The badge live-resolves the sender's title from the kanban store and falls back to a greyed snapshot when the task isn't loaded (cross-workspace, deleted, etc.). Adds a non-subscribing useTaskById selector so badges don't fan out WS subscriptions per message. * test(e2e): cover cross-task agent message attribution Eight scenarios driven through the mock-agent's MCP script directive: queue path, prompt path, self-message rejection, unknown-target error, badge link target, live title re-resolution after rename, kandev-system wrapper preservation in raw_content, and prompt bodies containing their own kandev-system tags. Adds an updateTaskTitle helper to ApiClient. * fix: address CodeRabbit + Greptile P1 findings on PR #819 - requeueMessage now forwards queuedMsg.Metadata via QueueMessageWithMetadata so sender attribution survives transient delivery failures and retries. - CREATED-session branch in handleMessageTask records the user message AFTER StartCreatedSession succeeds, so a launch failure no longer leaves an orphaned attributed chat row. - SenderTaskBadge: collapse identical displayTitle/fullTitle locals. - E2E: extract waitForTargetIdle helper; replace the 1.5s raw sleep in the collision-check test with the same poll the rest of the suite uses. * fix: address Claude review suggestions on PR #819 - wrapAgentMessage strips </kandev-system> from the embedded sender title before formatting. Without this, a title containing the closing tag would short-circuit the non-greedy strip regex and leak the attribution tail into the visible UI bubble. Metadata snapshot keeps the original title so the badge still displays it. - Add a regression test covering the malicious-title case. - Document the deliberate cross-workspace messaging policy on the sender lookup so future readers don't add a workspace-scope check (Q13 of the feature plan locked this as intentional).
134 lines
4.7 KiB
TypeScript
134 lines
4.7 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { ReactNode } from "react";
|
|
import { render } from "@testing-library/react";
|
|
import { StateProvider } from "@/components/state-provider";
|
|
import { ChatMessage } from "./chat-message";
|
|
import type { Message } from "@/lib/types/http";
|
|
|
|
const SENDER_TASK_ID = "task-sender";
|
|
const SENDER_TITLE = "Fix login bug";
|
|
const SENDER_BADGE_SELECTOR = "[data-testid='sender-task-badge']";
|
|
|
|
function userMessage(overrides: Partial<Message>): Message {
|
|
return {
|
|
id: "msg-1",
|
|
session_id: "sess-1",
|
|
task_id: "task-target",
|
|
author_type: "user",
|
|
content: "hello",
|
|
type: "message",
|
|
created_at: "2026-05-04T00:00:00Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function wrapper(tasks: Array<{ id: string; title: string }> = []) {
|
|
return function Wrapper({ children }: { children: ReactNode }) {
|
|
return (
|
|
<StateProvider
|
|
initialState={{
|
|
// Seed the kanban slice so useTaskById can resolve sender tasks for
|
|
// live-title resolution; tests that exercise the deleted-sender
|
|
// fallback simply omit the sender from this list.
|
|
// The full Task shape isn't required by useTaskById — only id+title.
|
|
kanban: {
|
|
tasks: tasks.map((t) => ({
|
|
id: t.id,
|
|
title: t.title,
|
|
workflow_step_id: "",
|
|
priority: 0,
|
|
parent_id: undefined,
|
|
})),
|
|
} as unknown as never,
|
|
}}
|
|
>
|
|
{children}
|
|
</StateProvider>
|
|
);
|
|
};
|
|
}
|
|
|
|
function renderWithSender(
|
|
tasks: Array<{ id: string; title: string }>,
|
|
metadata: Partial<Message["metadata"] & object>,
|
|
) {
|
|
const Wrapper = wrapper(tasks);
|
|
return render(
|
|
<Wrapper>
|
|
<ChatMessage comment={userMessage({ metadata })} label="Message" className="" />
|
|
</Wrapper>,
|
|
);
|
|
}
|
|
|
|
describe("ChatMessage sender badge", () => {
|
|
it("renders the sender badge when sender_task_id is present in metadata", () => {
|
|
const { container } = renderWithSender([{ id: SENDER_TASK_ID, title: SENDER_TITLE }], {
|
|
sender_task_id: SENDER_TASK_ID,
|
|
sender_task_title: SENDER_TITLE,
|
|
sender_session_id: "sender-sess",
|
|
});
|
|
|
|
const badge = container.querySelector(SENDER_BADGE_SELECTOR);
|
|
expect(badge).not.toBeNull();
|
|
expect(badge?.getAttribute("data-sender-task-id")).toBe(SENDER_TASK_ID);
|
|
expect(badge?.textContent).toContain(SENDER_TITLE);
|
|
});
|
|
|
|
it("links the badge to the source task when the sender is loaded", () => {
|
|
const { container } = renderWithSender([{ id: SENDER_TASK_ID, title: SENDER_TITLE }], {
|
|
sender_task_id: SENDER_TASK_ID,
|
|
sender_task_title: SENDER_TITLE,
|
|
});
|
|
|
|
const link = container.querySelector(`a[href='/t/${SENDER_TASK_ID}']`);
|
|
expect(link).not.toBeNull();
|
|
});
|
|
|
|
it("renders a non-clickable greyed badge when sender task is unknown", () => {
|
|
// No tasks seeded — sender task is "deleted" or cross-workspace.
|
|
const { container } = renderWithSender([], {
|
|
sender_task_id: "task-deleted",
|
|
sender_task_title: "Old title",
|
|
});
|
|
|
|
const badge = container.querySelector(SENDER_BADGE_SELECTOR);
|
|
expect(badge).not.toBeNull();
|
|
expect(container.querySelector("a[href='/t/task-deleted']")).toBeNull();
|
|
// Falls back to the snapshotted title rather than blanking the badge.
|
|
expect(badge?.textContent).toContain("Old title");
|
|
});
|
|
|
|
it("uses the live title when it differs from the snapshot", () => {
|
|
// The badge re-resolves the title from the kanban store so renames are
|
|
// reflected without re-sending the message.
|
|
const { container } = renderWithSender([{ id: SENDER_TASK_ID, title: "Renamed task" }], {
|
|
sender_task_id: SENDER_TASK_ID,
|
|
sender_task_title: "Old name",
|
|
});
|
|
|
|
const badge = container.querySelector(SENDER_BADGE_SELECTOR);
|
|
expect(badge?.textContent).toContain("Renamed task");
|
|
expect(badge?.textContent).not.toContain("Old name");
|
|
});
|
|
|
|
it("truncates very long titles for display", () => {
|
|
const longTitle = "This is a really long task title that should be truncated";
|
|
const { container } = renderWithSender([{ id: SENDER_TASK_ID, title: longTitle }], {
|
|
sender_task_id: SENDER_TASK_ID,
|
|
sender_task_title: longTitle,
|
|
});
|
|
|
|
const badge = container.querySelector(SENDER_BADGE_SELECTOR);
|
|
expect(badge).not.toBeNull();
|
|
// The badge text must contain the ellipsis (truncated) and not the full title.
|
|
expect(badge?.textContent).toContain("…");
|
|
expect(badge?.textContent ?? "").not.toContain(longTitle);
|
|
});
|
|
|
|
it("does not render a sender badge when metadata has no sender_task_id", () => {
|
|
const { container } = renderWithSender([], { plan_mode: true });
|
|
|
|
expect(container.querySelector(SENDER_BADGE_SELECTOR)).toBeNull();
|
|
});
|
|
});
|