mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-11 18:05:58 +00:00
Show IM channel source on threads
This commit is contained in:
@@ -274,6 +274,22 @@ def _response_metadata(base_metadata: dict[str, Any], *, pending_clarification:
|
|||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _thread_channel_metadata(msg: InboundMessage) -> dict[str, Any]:
|
||||||
|
channel_source: dict[str, Any] = {
|
||||||
|
"type": "im_channel",
|
||||||
|
"provider": msg.channel_name,
|
||||||
|
"chat_id": msg.chat_id,
|
||||||
|
}
|
||||||
|
if msg.topic_id:
|
||||||
|
channel_source["topic_id"] = msg.topic_id
|
||||||
|
if msg.thread_ts:
|
||||||
|
channel_source["thread_ts"] = msg.thread_ts
|
||||||
|
if msg.connection_id:
|
||||||
|
channel_source["connection_id"] = msg.connection_id
|
||||||
|
|
||||||
|
return {"channel_source": channel_source}
|
||||||
|
|
||||||
|
|
||||||
def _extract_text_content(content: Any) -> str:
|
def _extract_text_content(content: Any) -> str:
|
||||||
"""Extract text from a streaming payload content field."""
|
"""Extract text from a streaming payload content field."""
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
@@ -943,16 +959,27 @@ class ChannelManager:
|
|||||||
|
|
||||||
async def _create_thread(self, client, msg: InboundMessage) -> str:
|
async def _create_thread(self, client, msg: InboundMessage) -> str:
|
||||||
"""Create a new thread through Gateway and store the mapping."""
|
"""Create a new thread through Gateway and store the mapping."""
|
||||||
|
metadata = _thread_channel_metadata(msg)
|
||||||
owner_headers = _owner_headers(msg)
|
owner_headers = _owner_headers(msg)
|
||||||
if owner_headers:
|
if owner_headers:
|
||||||
thread = await client.threads.create(headers=owner_headers)
|
thread = await client.threads.create(metadata=metadata, headers=owner_headers)
|
||||||
else:
|
else:
|
||||||
thread = await client.threads.create()
|
thread = await client.threads.create(metadata=metadata)
|
||||||
thread_id = thread["thread_id"]
|
thread_id = thread["thread_id"]
|
||||||
await self._store_thread_id(msg, thread_id)
|
await self._store_thread_id(msg, thread_id)
|
||||||
logger.info("[Manager] new thread created through Gateway: thread_id=%s for chat_id=%s topic_id=%s", thread_id, msg.chat_id, msg.topic_id)
|
logger.info("[Manager] new thread created through Gateway: thread_id=%s for chat_id=%s topic_id=%s", thread_id, msg.chat_id, msg.topic_id)
|
||||||
return thread_id
|
return thread_id
|
||||||
|
|
||||||
|
async def _update_thread_channel_metadata(self, client, msg: InboundMessage, thread_id: str) -> None:
|
||||||
|
"""Best-effort source metadata backfill for existing IM-created threads."""
|
||||||
|
update_kwargs: dict[str, Any] = {"metadata": _thread_channel_metadata(msg)}
|
||||||
|
if owner_headers := _owner_headers(msg):
|
||||||
|
update_kwargs["headers"] = owner_headers
|
||||||
|
try:
|
||||||
|
await client.threads.update(thread_id, **update_kwargs)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[Manager] failed to update channel metadata for thread_id=%s", thread_id, exc_info=True)
|
||||||
|
|
||||||
async def _handle_chat(self, msg: InboundMessage, extra_context: dict[str, Any] | None = None) -> None:
|
async def _handle_chat(self, msg: InboundMessage, extra_context: dict[str, Any] | None = None) -> None:
|
||||||
client = self._get_client()
|
client = self._get_client()
|
||||||
|
|
||||||
@@ -962,6 +989,7 @@ class ChannelManager:
|
|||||||
thread_id = await self._lookup_thread_id(msg)
|
thread_id = await self._lookup_thread_id(msg)
|
||||||
if thread_id:
|
if thread_id:
|
||||||
logger.info("[Manager] reusing thread: thread_id=%s for topic_id=%s", thread_id, msg.topic_id)
|
logger.info("[Manager] reusing thread: thread_id=%s for topic_id=%s", thread_id, msg.topic_id)
|
||||||
|
await self._update_thread_channel_metadata(client, msg, thread_id)
|
||||||
|
|
||||||
# No existing thread found — create a new one
|
# No existing thread found — create a new one
|
||||||
if thread_id is None:
|
if thread_id is None:
|
||||||
@@ -1202,9 +1230,7 @@ class ChannelManager:
|
|||||||
if reply is None and command == "new":
|
if reply is None and command == "new":
|
||||||
# Create a new thread through Gateway
|
# Create a new thread through Gateway
|
||||||
client = self._get_client()
|
client = self._get_client()
|
||||||
thread = await client.threads.create()
|
await self._create_thread(client, msg)
|
||||||
new_thread_id = thread["thread_id"]
|
|
||||||
await self._store_thread_id(msg, new_thread_id)
|
|
||||||
reply = "New conversation started."
|
reply = "New conversation started."
|
||||||
elif reply is None and command == "status":
|
elif reply is None and command == "status":
|
||||||
thread_id = await self._lookup_thread_id(msg)
|
thread_id = await self._lookup_thread_id(msg)
|
||||||
|
|||||||
@@ -487,6 +487,7 @@ def _make_mock_langgraph_client(thread_id="test-thread-123", run_result=None):
|
|||||||
|
|
||||||
# threads.create() returns a Thread-like dict
|
# threads.create() returns a Thread-like dict
|
||||||
mock_client.threads.create = AsyncMock(return_value={"thread_id": thread_id})
|
mock_client.threads.create = AsyncMock(return_value={"thread_id": thread_id})
|
||||||
|
mock_client.threads.update = AsyncMock(return_value={"thread_id": thread_id})
|
||||||
|
|
||||||
# threads.get() returns thread info (succeeds by default)
|
# threads.get() returns thread info (succeeds by default)
|
||||||
mock_client.threads.get = AsyncMock(return_value={"thread_id": thread_id})
|
mock_client.threads.get = AsyncMock(return_value={"thread_id": thread_id})
|
||||||
@@ -667,16 +668,34 @@ class TestChannelManager:
|
|||||||
|
|
||||||
await manager.start()
|
await manager.start()
|
||||||
|
|
||||||
inbound = InboundMessage(channel_name="test", chat_id="chat1", user_id="user1", text="hi")
|
inbound = InboundMessage(
|
||||||
|
channel_name="test",
|
||||||
|
chat_id="chat1",
|
||||||
|
user_id="user1",
|
||||||
|
text="hi",
|
||||||
|
topic_id="topic1",
|
||||||
|
thread_ts="msg1",
|
||||||
|
connection_id="conn1",
|
||||||
|
)
|
||||||
await bus.publish_inbound(inbound)
|
await bus.publish_inbound(inbound)
|
||||||
await _wait_for(lambda: len(outbound_received) >= 1)
|
await _wait_for(lambda: len(outbound_received) >= 1)
|
||||||
await manager.stop()
|
await manager.stop()
|
||||||
|
|
||||||
# Thread should be created through Gateway
|
# Thread should be created through Gateway
|
||||||
mock_client.threads.create.assert_called_once()
|
mock_client.threads.create.assert_called_once()
|
||||||
|
assert mock_client.threads.create.call_args.kwargs["metadata"] == {
|
||||||
|
"channel_source": {
|
||||||
|
"type": "im_channel",
|
||||||
|
"provider": "test",
|
||||||
|
"chat_id": "chat1",
|
||||||
|
"topic_id": "topic1",
|
||||||
|
"thread_ts": "msg1",
|
||||||
|
"connection_id": "conn1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Thread ID should be stored
|
# Thread ID should be stored
|
||||||
thread_id = store.get_thread_id("test", "chat1")
|
thread_id = store.get_thread_id("test", "chat1", topic_id="topic1")
|
||||||
assert thread_id == "test-thread-123"
|
assert thread_id == "test-thread-123"
|
||||||
|
|
||||||
# runs.wait should be called with the thread_id
|
# runs.wait should be called with the thread_id
|
||||||
@@ -2003,6 +2022,17 @@ class TestChannelManager:
|
|||||||
|
|
||||||
# threads.create should be called only ONCE (second message reuses the thread)
|
# threads.create should be called only ONCE (second message reuses the thread)
|
||||||
mock_client.threads.create.assert_called_once()
|
mock_client.threads.create.assert_called_once()
|
||||||
|
mock_client.threads.update.assert_called_once_with(
|
||||||
|
"topic-thread-1",
|
||||||
|
metadata={
|
||||||
|
"channel_source": {
|
||||||
|
"type": "im_channel",
|
||||||
|
"provider": "test",
|
||||||
|
"chat_id": "chat1",
|
||||||
|
"topic_id": "topic-root-123",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# Both runs.wait calls should use the same thread_id
|
# Both runs.wait calls should use the same thread_id
|
||||||
assert mock_client.runs.wait.call_count == 2
|
assert mock_client.runs.wait.call_count == 2
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
ThreadChannelBadge,
|
||||||
|
ThreadChannelIcon,
|
||||||
|
} from "@/components/workspace/thread-channel-source";
|
||||||
import {
|
import {
|
||||||
WorkspaceBody,
|
WorkspaceBody,
|
||||||
WorkspaceContainer,
|
WorkspaceContainer,
|
||||||
@@ -12,7 +16,11 @@ import {
|
|||||||
} from "@/components/workspace/workspace-container";
|
} from "@/components/workspace/workspace-container";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { useThreads } from "@/core/threads/hooks";
|
import { useThreads } from "@/core/threads/hooks";
|
||||||
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
|
import {
|
||||||
|
channelSourceOfThread,
|
||||||
|
pathOfThread,
|
||||||
|
titleOfThread,
|
||||||
|
} from "@/core/threads/utils";
|
||||||
import { formatTimeAgo } from "@/core/utils/datetime";
|
import { formatTimeAgo } from "@/core/utils/datetime";
|
||||||
|
|
||||||
export default function ChatsPage() {
|
export default function ChatsPage() {
|
||||||
@@ -47,11 +55,20 @@ export default function ChatsPage() {
|
|||||||
<main className="min-h-0 flex-1">
|
<main className="min-h-0 flex-1">
|
||||||
<ScrollArea className="size-full py-4">
|
<ScrollArea className="size-full py-4">
|
||||||
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
|
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
|
||||||
{filteredThreads?.map((thread) => (
|
{filteredThreads?.map((thread) => {
|
||||||
|
const channelSource = channelSourceOfThread(thread);
|
||||||
|
return (
|
||||||
<Link key={thread.thread_id} href={pathOfThread(thread)}>
|
<Link key={thread.thread_id} href={pathOfThread(thread)}>
|
||||||
<div className="flex flex-col gap-2 border-b p-4">
|
<div className="flex flex-col gap-2 border-b p-4">
|
||||||
<div>
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<div>{titleOfThread(thread)}</div>
|
<ThreadChannelIcon source={channelSource} />
|
||||||
|
<div className="min-w-0 flex-1 truncate">
|
||||||
|
{titleOfThread(thread)}
|
||||||
|
</div>
|
||||||
|
<ThreadChannelBadge
|
||||||
|
source={channelSource}
|
||||||
|
className="hidden sm:inline-flex"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{thread.updated_at && (
|
{thread.updated_at && (
|
||||||
<div className="text-muted-foreground text-sm">
|
<div className="text-muted-foreground text-sm">
|
||||||
@@ -60,7 +77,8 @@ export default function ChatsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -55,10 +55,16 @@ import {
|
|||||||
useThreads,
|
useThreads,
|
||||||
} from "@/core/threads/hooks";
|
} from "@/core/threads/hooks";
|
||||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||||
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
|
import {
|
||||||
|
channelSourceOfThread,
|
||||||
|
pathOfThread,
|
||||||
|
titleOfThread,
|
||||||
|
} from "@/core/threads/utils";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { isIMEComposing } from "@/lib/ime";
|
import { isIMEComposing } from "@/lib/ime";
|
||||||
|
|
||||||
|
import { ThreadChannelIcon } from "./thread-channel-source";
|
||||||
|
|
||||||
export function RecentChatList() {
|
export function RecentChatList() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -182,6 +188,7 @@ export function RecentChatList() {
|
|||||||
<div className="flex w-full flex-col gap-1">
|
<div className="flex w-full flex-col gap-1">
|
||||||
{threads.map((thread) => {
|
{threads.map((thread) => {
|
||||||
const isActive = pathOfThread(thread) === pathname;
|
const isActive = pathOfThread(thread) === pathname;
|
||||||
|
const channelSource = channelSourceOfThread(thread);
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem
|
<SidebarMenuItem
|
||||||
key={thread.thread_id}
|
key={thread.thread_id}
|
||||||
@@ -190,10 +197,23 @@ export function RecentChatList() {
|
|||||||
<SidebarMenuButton isActive={isActive} asChild>
|
<SidebarMenuButton isActive={isActive} asChild>
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
className="text-muted-foreground flex min-w-0 items-center gap-1.5 whitespace-nowrap pr-7 group-hover/side-menu-item:overflow-hidden"
|
||||||
href={pathOfThread(thread)}
|
href={pathOfThread(thread)}
|
||||||
>
|
>
|
||||||
|
<ThreadChannelIcon source={channelSource} />
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
{titleOfThread(thread)}
|
{titleOfThread(thread)}
|
||||||
|
</span>
|
||||||
|
{channelSource && (
|
||||||
|
<span
|
||||||
|
className="bg-muted text-muted-foreground ml-auto inline-flex h-5 max-w-14 shrink-0 items-center rounded-md px-1.5 text-[10px] font-medium"
|
||||||
|
title={`${channelSource.label} channel`}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{channelSource.label}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChannelProviderIcon } from "@/components/workspace/channels/channel-provider-icon";
|
||||||
|
import type { ChannelThreadSource } from "@/core/threads/utils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type ThreadChannelIconProps = {
|
||||||
|
source: ChannelThreadSource | null;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThreadChannelIcon({
|
||||||
|
source,
|
||||||
|
className,
|
||||||
|
}: ThreadChannelIconProps) {
|
||||||
|
if (!source) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-label={`${source.label} channel`}
|
||||||
|
title={`${source.label} channel`}
|
||||||
|
className={cn("inline-flex shrink-0 items-center", className)}
|
||||||
|
>
|
||||||
|
<ChannelProviderIcon provider={source.provider} className="size-4" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThreadChannelBadgeProps = {
|
||||||
|
source: ChannelThreadSource | null;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThreadChannelBadge({
|
||||||
|
source,
|
||||||
|
className,
|
||||||
|
}: ThreadChannelBadgeProps) {
|
||||||
|
if (!source) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground inline-flex h-6 max-w-32 items-center gap-1 rounded-md px-2 text-xs font-medium",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
title={`${source.label} channel`}
|
||||||
|
>
|
||||||
|
<ChannelProviderIcon provider={source.provider} className="size-3.5" />
|
||||||
|
<span className="truncate">{source.label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,12 @@ import type { Message } from "@langchain/langgraph-sdk";
|
|||||||
|
|
||||||
import type { AgentThread, AgentThreadContext } from "./types";
|
import type { AgentThread, AgentThreadContext } from "./types";
|
||||||
|
|
||||||
|
export type ChannelThreadSource = {
|
||||||
|
type: "im_channel";
|
||||||
|
provider: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
type ThreadRouteTarget =
|
type ThreadRouteTarget =
|
||||||
| string
|
| string
|
||||||
| {
|
| {
|
||||||
@@ -49,3 +55,42 @@ export function textOfMessage(message: Message) {
|
|||||||
export function titleOfThread(thread: AgentThread) {
|
export function titleOfThread(thread: AgentThread) {
|
||||||
return thread.values?.title ?? "Untitled";
|
return thread.values?.title ?? "Untitled";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CHANNEL_PROVIDER_LABELS: Record<string, string> = {
|
||||||
|
dingtalk: "DingTalk",
|
||||||
|
discord: "Discord",
|
||||||
|
feishu: "Feishu",
|
||||||
|
slack: "Slack",
|
||||||
|
telegram: "Telegram",
|
||||||
|
wechat: "WeChat",
|
||||||
|
wecom: "WeCom",
|
||||||
|
};
|
||||||
|
|
||||||
|
function labelOfChannelProvider(provider: string) {
|
||||||
|
return CHANNEL_PROVIDER_LABELS[provider] ?? provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function channelSourceOfThread(
|
||||||
|
thread: Pick<AgentThread, "metadata">,
|
||||||
|
): ChannelThreadSource | null {
|
||||||
|
const source = thread.metadata?.channel_source;
|
||||||
|
if (!source || typeof source !== "object" || Array.isArray(source)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Reflect.get(source, "type") !== "im_channel") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = Reflect.get(source, "provider");
|
||||||
|
if (typeof provider !== "string" || provider.trim().length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedProvider = provider.trim().toLowerCase();
|
||||||
|
return {
|
||||||
|
type: "im_channel",
|
||||||
|
provider: normalizedProvider,
|
||||||
|
label: labelOfChannelProvider(normalizedProvider),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -152,4 +152,45 @@ test.describe("Thread history", () => {
|
|||||||
});
|
});
|
||||||
await expect(main.getByText("Second conversation")).toBeVisible();
|
await expect(main.getByText("Second conversation")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("IM channel threads show their source in thread lists", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
mockLangGraphAPI(page, {
|
||||||
|
threads: [
|
||||||
|
{
|
||||||
|
thread_id: MOCK_THREAD_ID,
|
||||||
|
title: "Feishu conversation",
|
||||||
|
updated_at: "2025-06-03T12:00:00Z",
|
||||||
|
metadata: {
|
||||||
|
channel_source: {
|
||||||
|
type: "im_channel",
|
||||||
|
provider: "feishu",
|
||||||
|
chat_id: "oc_mock",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/workspace/chats/new");
|
||||||
|
|
||||||
|
const sidebarThread = page.locator(
|
||||||
|
`a[href='/workspace/chats/${MOCK_THREAD_ID}']`,
|
||||||
|
);
|
||||||
|
await expect(sidebarThread).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(
|
||||||
|
sidebarThread.getByLabel("Feishu channel"),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto("/workspace/chats");
|
||||||
|
|
||||||
|
const mainThread = page.locator("main").locator(
|
||||||
|
`a[href='/workspace/chats/${MOCK_THREAD_ID}']`,
|
||||||
|
);
|
||||||
|
await expect(mainThread.getByText("Feishu conversation")).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
await expect(mainThread.getByText("Feishu", { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export type MockThread = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
agent_name?: string;
|
agent_name?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
messages?: unknown[];
|
messages?: unknown[];
|
||||||
artifacts?: string[];
|
artifacts?: string[];
|
||||||
};
|
};
|
||||||
@@ -90,7 +91,10 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||||||
thread_id: t.thread_id,
|
thread_id: t.thread_id,
|
||||||
created_at: "2025-01-01T00:00:00Z",
|
created_at: "2025-01-01T00:00:00Z",
|
||||||
updated_at: t.updated_at ?? "2025-01-01T00:00:00Z",
|
updated_at: t.updated_at ?? "2025-01-01T00:00:00Z",
|
||||||
metadata: t.agent_name ? { agent_name: t.agent_name } : {},
|
metadata: {
|
||||||
|
...(t.metadata ?? {}),
|
||||||
|
...(t.agent_name ? { agent_name: t.agent_name } : {}),
|
||||||
|
},
|
||||||
status: "idle",
|
status: "idle",
|
||||||
values: { title: t.title ?? "Untitled" },
|
values: { title: t.title ?? "Untitled" },
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
|
|
||||||
import { pathOfThread } from "@/core/threads/utils";
|
import { channelSourceOfThread, pathOfThread } from "@/core/threads/utils";
|
||||||
|
|
||||||
test("uses standard chat route when thread has no agent context", () => {
|
test("uses standard chat route when thread has no agent context", () => {
|
||||||
expect(pathOfThread("thread-123")).toBe("/workspace/chats/thread-123");
|
expect(pathOfThread("thread-123")).toBe("/workspace/chats/thread-123");
|
||||||
@@ -44,3 +44,40 @@ test("prefers context.agent_name over metadata.agent_name", () => {
|
|||||||
}),
|
}),
|
||||||
).toBe("/workspace/agents/from-context/chats/thread-789");
|
).toBe("/workspace/agents/from-context/chats/thread-789");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("reads IM channel source metadata", () => {
|
||||||
|
expect(
|
||||||
|
channelSourceOfThread({
|
||||||
|
metadata: {
|
||||||
|
channel_source: {
|
||||||
|
type: "im_channel",
|
||||||
|
provider: "feishu",
|
||||||
|
chat_id: "oc_123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "im_channel",
|
||||||
|
provider: "feishu",
|
||||||
|
label: "Feishu",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores threads without valid IM channel source metadata", () => {
|
||||||
|
expect(channelSourceOfThread({ metadata: {} })).toBeNull();
|
||||||
|
expect(
|
||||||
|
channelSourceOfThread({
|
||||||
|
metadata: { channel_source: { provider: "" } },
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
channelSourceOfThread({
|
||||||
|
metadata: {
|
||||||
|
channel_source: {
|
||||||
|
type: "other",
|
||||||
|
provider: "feishu",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user