* Add user-owned IM channel connections
* Fix dev startup and channel connect popup
* Use async channel connect flow
* Harden dev service daemon startup
* Support local IM channel connections
* Align IM connections with local channels
* Fix safe user id digest algorithm
* Address Copilot IM channel feedback
* Address IM channel review comments
* Support all integrated IM channel connections
* Format additional channel connection tests
* Keep unavailable channel connect buttons clickable
* Fix IM channel provider icons
* Add runtime setup for enabled IM channels
* Guard global shortcut key handling
* Keep configured IM channels editable
* Avoid password autofill for channel secrets
* Make channel threads visible to connection owners
* Persist IM runtime config locally
* Allow disconnecting runtime IM channels
* Route no-auth channel sessions to local user
* Use default user for auth-disabled local mode
* Show IM channel source on threads
* Prefill IM channel runtime config
* Reflect IM channel runtime health
* Ignore Feishu message read events
* Ignore Feishu non-content message events
* Let setup wizard enable IM channels
* Fix frontend formatting after merge
* Stabilize backend tests without local config
* Isolate channel runtime config tests
* Address channel connection review comments
* Use sha256 user buckets with legacy migration
* Ensure runtime IM channels are ready after restart
* Persist disconnected IM channel state
* Address channel connection review comments
* Address channel connection review findings
Frontend connect flow:
- Open the runtime-config dialog only when a provider still needs
credentials; configured providers go straight to the connect flow, so
the binding-code/deep-link path is reachable from the UI again.
- After saving credentials, continue into the connect flow when a user
binding is still required (multi-user mode) instead of stopping at a
"Connected" toast.
- Extract shared provider-state helpers to core/channels/provider-state
and add unit + e2e coverage for the direct-connect and
configure-then-connect paths.
Provider status semantics:
- Report connection_status from the user's newest connection row;
with no binding it is not_connected, except in auth-disabled local
mode where a configured running channel is effectively connected.
Concurrency and event-loop correctness:
- Offload ChannelRuntimeConfigStore construction and writes, channel
service construction, and Slack connection replies to threads; add a
tests/blocking_io/ anchor for the runtime-config handlers.
- Consume binding codes with a conditional UPDATE so a code can only be
used once under concurrent workers; retry upsert_connection as an
update when a concurrent insert wins the unique constraint.
- Serialize ensure_channel_ready per channel so concurrent provider
polls cannot double-start a channel worker.
Config and migration hardening:
- Stop mutating the get_app_config()-cached Telegram provider config;
the runtime store now owns the UI-entered bot username.
- Register channel_connections in STARTUP_ONLY_FIELDS with the
standardized startup-only Field description.
- Match the legacy unsafe-id bucket by recomputing its exact SHA-1 name
so another user's same-prefix bucket can never be migrated.
- Remove the unused Telegram process_webhook_update path and document
src/core/channels in the frontend docs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Address PR review comments on authz scoping and channel runtime
Security (review feedback from ShenAC-SAC):
- Scope internal-token callers to the connection owner carried in
X-DeerFlow-Owner-User-Id instead of bypassing owner checks outright,
in both require_permission(owner_check=True) and the stateless run
endpoints. Internal callers keep access to their own and
shared/legacy threads, and may claim a default-owned channel thread
for its real owner, but a leaked internal token no longer grants
cross-user thread access.
- Require admin privileges for POST/DELETE /api/channels/{provider}/
runtime-config: runtime credentials and channel workers are
instance-wide shared state (same model as the MCP config API).
Read-only provider listing stays available to all users.
Performance (review feedback from willem-bd):
- Skip the redundant thread channel-metadata PATCH after the first
successful backfill per thread.
- Reuse the per-connection Slack WebClient until its token changes
instead of constructing one per outbound message.
- Reconcile channel readiness for all providers concurrently in
GET /api/channels/providers.
Also resolve the code-quality unused-import flag in the blocking-io
anchor by pre-importing the channel service via importlib.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix prettier formatting in provider-state test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Reconcile UI runtime channel config with config reload on restart
Main now reloads a channel's config.yaml entry on restart_channel()
(#3514, issue #3497). Adapt the user-owned connection flow to coexist:
- configure_channel() restarts with reload_config=False — the caller
just supplied the authoritative config (browser-entered credentials
that are never written to config.yaml), so a file reload must not
clobber it with the stale on-disk entry.
- _load_channel_config() re-applies the UI runtime-store overlay used
at startup, so an operator-triggered restart keeps browser-entered
credentials for channels without a config.yaml entry and does not
resurrect a channel disconnected from the UI.
- Offload the reload's disk IO (config.yaml + runtime store) with
asyncio.to_thread, matching the blocking-IO policy on this branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
421 lines
12 KiB
TypeScript
421 lines
12 KiB
TypeScript
/**
|
|
* Shared mock helpers for E2E tests.
|
|
*
|
|
* Intercepts all LangGraph / Backend API endpoints so tests can run without
|
|
* a real backend. Each test file imports `mockLangGraphAPI` and
|
|
* `handleRunStream` from here.
|
|
*/
|
|
|
|
import type { Page, Route } from "@playwright/test";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants — deterministic IDs used across tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const MOCK_THREAD_ID = "00000000-0000-0000-0000-000000000001";
|
|
export const MOCK_THREAD_ID_2 = "00000000-0000-0000-0000-000000000002";
|
|
export const MOCK_RUN_ID = "00000000-0000-0000-0000-000000000099";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type MockThread = {
|
|
thread_id: string;
|
|
title?: string;
|
|
updated_at?: string;
|
|
agent_name?: string;
|
|
metadata?: Record<string, unknown>;
|
|
messages?: unknown[];
|
|
artifacts?: string[];
|
|
};
|
|
|
|
export type MockAgent = {
|
|
name: string;
|
|
description?: string;
|
|
system_prompt?: string;
|
|
};
|
|
|
|
export type MockSkill = {
|
|
name: string;
|
|
description: string;
|
|
category?: string;
|
|
license?: string | null;
|
|
enabled?: boolean;
|
|
};
|
|
|
|
export type MockAPIOptions = {
|
|
threads?: MockThread[];
|
|
agents?: MockAgent[];
|
|
skills?: MockSkill[];
|
|
};
|
|
|
|
const DEFAULT_SKILLS: MockSkill[] = [
|
|
{
|
|
name: "data-analysis",
|
|
description: "Analyze structured data and produce charts.",
|
|
category: "public",
|
|
enabled: true,
|
|
},
|
|
{
|
|
name: "frontend-design",
|
|
description: "Create polished frontend interfaces.",
|
|
category: "public",
|
|
enabled: true,
|
|
},
|
|
{
|
|
name: "disabled-skill",
|
|
description: "Hidden from slash autocomplete.",
|
|
category: "public",
|
|
enabled: false,
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// mockLangGraphAPI
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Mock all LangGraph API endpoints that the frontend calls on page load and
|
|
* during message sending. Without these mocks the pages would hang waiting
|
|
* for a real backend.
|
|
*/
|
|
export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|
const threads = options?.threads ?? [];
|
|
const agents = options?.agents ?? [];
|
|
const skills = options?.skills ?? DEFAULT_SKILLS;
|
|
|
|
// Thread search — sidebar thread list & chats list page
|
|
void page.route("**/api/langgraph/threads/search", async (route) => {
|
|
const body = threads.map((t) => ({
|
|
thread_id: t.thread_id,
|
|
created_at: "2025-01-01T00:00:00Z",
|
|
updated_at: t.updated_at ?? "2025-01-01T00:00:00Z",
|
|
metadata: {
|
|
...(t.metadata ?? {}),
|
|
...(t.agent_name ? { agent_name: t.agent_name } : {}),
|
|
},
|
|
status: "idle",
|
|
values: { title: t.title ?? "Untitled" },
|
|
}));
|
|
|
|
let limit: number | undefined;
|
|
let offset = 0;
|
|
try {
|
|
const postData = route.request().postDataJSON() as {
|
|
limit?: number;
|
|
offset?: number;
|
|
} | null;
|
|
if (postData) {
|
|
if (typeof postData.limit === "number") {
|
|
limit = postData.limit;
|
|
}
|
|
if (typeof postData.offset === "number") {
|
|
offset = postData.offset;
|
|
}
|
|
}
|
|
} catch {
|
|
// No / invalid JSON body — fall back to returning the full list.
|
|
}
|
|
|
|
const sliced =
|
|
typeof limit === "number" ? body.slice(offset, offset + limit) : body;
|
|
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(sliced),
|
|
});
|
|
});
|
|
|
|
// Thread create — called when user sends first message in a new chat
|
|
void page.route("**/api/langgraph/threads", (route) => {
|
|
if (route.request().method() === "POST") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
thread_id: MOCK_THREAD_ID,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
metadata: {},
|
|
status: "idle",
|
|
values: {},
|
|
}),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
// Thread update (PATCH) — metadata update after creation
|
|
void page.route("**/api/langgraph/threads/*", (route) => {
|
|
if (route.request().method() === "PATCH") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ thread_id: MOCK_THREAD_ID }),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
// Thread history — useStream fetches state history on mount
|
|
void page.route("**/api/langgraph/threads/*/history", (route) => {
|
|
const url = route.request().url();
|
|
|
|
// For threads that exist in our mock data, return history with messages
|
|
const matchingThread = threads.find((t) => url.includes(t.thread_id));
|
|
if (matchingThread) {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify([
|
|
{
|
|
values: {
|
|
title: matchingThread.title ?? "Untitled",
|
|
messages: matchingThread.messages ?? [
|
|
{
|
|
type: "human",
|
|
id: `msg-human-${matchingThread.thread_id}`,
|
|
content: [{ type: "text", text: "Previous question" }],
|
|
},
|
|
{
|
|
type: "ai",
|
|
id: `msg-ai-${matchingThread.thread_id}`,
|
|
content: `Response in thread ${matchingThread.title ?? matchingThread.thread_id}`,
|
|
},
|
|
],
|
|
artifacts: matchingThread.artifacts ?? [],
|
|
},
|
|
next: [],
|
|
metadata: {},
|
|
created_at: "2025-01-01T00:00:00Z",
|
|
parent_config: null,
|
|
},
|
|
]),
|
|
});
|
|
}
|
|
|
|
// New threads — empty history
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: "[]",
|
|
});
|
|
});
|
|
|
|
// Thread state — getState for individual thread
|
|
void page.route("**/api/langgraph/threads/*/state", (route) => {
|
|
if (route.request().method() === "GET") {
|
|
const url = route.request().url();
|
|
const matchingThread = threads.find((t) => url.includes(t.thread_id));
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
values: {
|
|
title: matchingThread?.title ?? "Untitled",
|
|
messages: matchingThread
|
|
? (matchingThread.messages ?? [
|
|
{
|
|
type: "human",
|
|
id: `msg-human-${matchingThread.thread_id}`,
|
|
content: [{ type: "text", text: "Previous question" }],
|
|
},
|
|
{
|
|
type: "ai",
|
|
id: `msg-ai-${matchingThread.thread_id}`,
|
|
content: `Response in thread ${matchingThread.title ?? matchingThread.thread_id}`,
|
|
},
|
|
])
|
|
: [],
|
|
artifacts: matchingThread?.artifacts ?? [],
|
|
},
|
|
next: [],
|
|
metadata: {},
|
|
created_at: "2025-01-01T00:00:00Z",
|
|
}),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
// The URL carries a query string (e.g. `?limit=10&offset=0`), which Playwright
|
|
// glob `*` does NOT cross, so we match with a regex anchored to `/runs`
|
|
// followed by `?` or end-of-string. This must NOT match `/runs/stream`.
|
|
void page.route(/\/api\/langgraph\/threads\/[^/]+\/runs(\?|$)/, (route) => {
|
|
if (route.request().method() === "GET") {
|
|
const url = route.request().url();
|
|
const matchingThread = threads.find((t) => url.includes(t.thread_id));
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(
|
|
matchingThread
|
|
? [
|
|
{
|
|
run_id: `run-${matchingThread.thread_id}`,
|
|
thread_id: matchingThread.thread_id,
|
|
assistant_id: "lead_agent",
|
|
status: "success",
|
|
metadata: {},
|
|
kwargs: {},
|
|
created_at: "2025-01-01T00:00:00Z",
|
|
updated_at:
|
|
matchingThread.updated_at ?? "2025-01-01T00:00:00Z",
|
|
},
|
|
]
|
|
: [],
|
|
),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
void page.route(
|
|
/\/api\/threads\/([^/]+)\/runs\/([^/]+)\/messages/,
|
|
(route) => {
|
|
if (route.request().method() === "GET") {
|
|
const url = route.request().url();
|
|
const matchingThread = threads.find((t) =>
|
|
url.includes(`/api/threads/${t.thread_id}/runs/`),
|
|
);
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: (matchingThread?.messages ?? []).map((message, index) => ({
|
|
run_id: `run-${matchingThread?.thread_id ?? "unknown"}`,
|
|
content: message,
|
|
metadata: { caller: "lead_agent" },
|
|
created_at: `2025-01-01T00:00:${String(index).padStart(2, "0")}Z`,
|
|
})),
|
|
hasMore: false,
|
|
}),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
},
|
|
);
|
|
|
|
// Run stream — returns a minimal SSE response with an AI message
|
|
void page.route("**/api/langgraph/runs/stream", handleRunStream);
|
|
void page.route("**/api/langgraph/threads/*/runs/stream", handleRunStream);
|
|
|
|
// Models list — model picker dropdown
|
|
void page.route("**/api/models", (route) => {
|
|
if (route.request().method() === "GET") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
models: [],
|
|
token_usage: { enabled: false },
|
|
}),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
// Skills list — settings page and slash autocomplete
|
|
void page.route("**/api/skills", (route) => {
|
|
if (route.request().method() === "GET") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ skills }),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
// Follow-up suggestions — input box auto-suggest after AI response
|
|
void page.route("**/api/threads/*/suggestions", (route) => {
|
|
if (route.request().method() === "POST") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ suggestions: [] }),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
// Agents list — sidebar & gallery page
|
|
void page.route("**/api/agents", (route) => {
|
|
if (route.request().method() === "GET") {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ agents }),
|
|
});
|
|
}
|
|
return route.fallback();
|
|
});
|
|
|
|
// Individual agent — agent chat page
|
|
void page.route("**/api/agents/*", (route) => {
|
|
if (route.request().method() === "GET") {
|
|
const url = route.request().url();
|
|
const agent = agents.find((a) => url.endsWith(`/api/agents/${a.name}`));
|
|
if (agent) {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(agent),
|
|
});
|
|
}
|
|
}
|
|
return route.fulfill({
|
|
status: 404,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ detail: "Agent not found" }),
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// handleRunStream
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Build a minimal SSE stream that the LangGraph SDK can parse.
|
|
* The stream returns a single AI message: "Hello from DeerFlow!".
|
|
*/
|
|
export function handleRunStream(route: Route) {
|
|
const events = [
|
|
{
|
|
event: "metadata",
|
|
data: { run_id: MOCK_RUN_ID, thread_id: MOCK_THREAD_ID },
|
|
},
|
|
{
|
|
event: "values",
|
|
data: {
|
|
messages: [
|
|
{
|
|
type: "human",
|
|
id: "msg-human-1",
|
|
content: [{ type: "text", text: "Hello" }],
|
|
},
|
|
{
|
|
type: "ai",
|
|
id: "msg-ai-1",
|
|
content: "Hello from DeerFlow!",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{ event: "end", data: {} },
|
|
];
|
|
|
|
const body = events
|
|
.map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`)
|
|
.join("");
|
|
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body,
|
|
});
|
|
}
|