diff --git a/CHANGELOG.md b/CHANGELOG.md index f1283ed80..ab2c7047c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,19 +7,36 @@ ## [Unreleased] ### 修复 -- 知识库预览旧版 Word(.doc)改为抽取文本;工作区、对话、技能和存储浏览改为下载,不再交给 docx 渲染器(#2155) -- 知识库上传进度只显示在正在接收文件的那个知识库 + +- `/compact` 压缩对话后上下文环形进度仍停留在压缩前的百分比:改为优先采用较新的服务端快照,并在回合结束时重新拉取 + +## [1.0.2] - 2026-10-18 + ### 新增 -- 定时任务支持一次性执行:到点跑完(含失败)后自动停用并留在列表中;停机超过 24 小时未跑到则记为错过 -- 个性化页的技能、子智能体、工具、插件、MBTI、记忆可以按权限单独开关 +- 两台 Octop 可以连起来用:通过云端协同使用对方的专家,能聊天,也能改对方工作区里的文件 +- 专家可以组队。主持人负责分派任务和收尾总结,成员的回复会回到聊天,也会同步到即时通讯 +- 新连接器:企查查、OpenAlex、语雀、Obsidian、Agent Mail,以及钉钉命令行(设备码登录后可查通讯录、日程和文档) +- 可以用公司的 LDAP 账号登录。Telegram 机器人可以限定哪些用户能跟它说话 +- 记忆可以手改「用户档案」和「强记忆」。和已有记忆打架时先请你确认,不会悄悄覆盖;也可以备份和恢复 +- 技能和技能包可以导出成一个 ZIP,换一台机器再导入。技能包可以关掉别人的打包下载 +- 定时任务可以只跑一次,跑完自动停用,仍留在列表里。聊天进行中可以再补一句,不用等这一轮结束 +- 每个专家可以设定默认对话方式:问答、规划或动手。侧栏可以按自己的账号重新排列 +- 语音支持腾讯云边说边出字。当前模型不可用时,可以按健康状态自动换一个 +- 知识库旁边可以按文件夹浏览文档。新增业务流程、知识工作等专家模板。内置助手可以回答产品和用法问题 +- 别的程序可以通过 Agent MCP 网关调用这个 Agent。ONNX 模型可以不连 Hugging Face 直接加载 ### 修复 -- Edge TTS 语速换算改成四舍五入:1.4 倍速请求 `+40%`(此前写成 `+39%`),0.8 倍速 `-20%`(此前 `-19%`) +- 中文 Windows 上,搜索或执行命令碰到编码问题时,不再把整轮对话判失败,也不再把命令输出弄丢 +- 旧版 Word(.doc)在知识库里改为显示抽出的文字,在工作区、对话和技能里改为下载 +- 云端团队刷新页面后,正在生成的成员回复还在。对方拒绝登录时,不会把你自己的账号退出 +- 删掉会话会一并清掉它留下的数据。远程存储卡住时,不再挡住整个服务启动 +- 邮件标题里没有按规范编码的中文,可以正常读出来 ### 变更 -- 轮转日志文件名改为 `octop.YYYY-MM-DD.log`;启动时把旧的 `octop.log.YYYY-MM-DD` 改成新名字 -- 定时任务卡片可以直接禁用或启用;聊天侧栏的历史按钮可以收起,也可以展开 -- 技能选择器把用户安装的技能排在内置技能前面 +- 聊天侧栏分成专家、团队、云端三块;还没有团队或云端连接时,可以直接去创建 +- 个性化里的技能、子智能体、工具、插件、性格和记忆可以分别授权,不必一次全开 +- 输入栏不常用的入口收进「+」。没有开启审批时,不再出现批准按钮 +- 飞牛提供 ARM 安装包,安装后走建账号向导 ## [1.0.2b9] - 2026-10-16 diff --git a/README.md b/README.md index 42e3f6407..24a883813 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@

Python 3.12+ License: MIT - Version + Version PyPI Code Style: Ruff GitHub stars diff --git a/README_CN.md b/README_CN.md index cceaae258..2d9c0fa4c 100644 --- a/README_CN.md +++ b/README_CN.md @@ -15,7 +15,7 @@

Python 3.12+ License: MIT - Version + Version PyPI Code Style: Ruff GitHub stars diff --git a/dashboard/src/api/modules/settings.ts b/dashboard/src/api/modules/settings.ts index 943aaafbd..c1ba70e02 100644 --- a/dashboard/src/api/modules/settings.ts +++ b/dashboard/src/api/modules/settings.ts @@ -1,4 +1,4 @@ -import { request } from "../request"; +import { bridgeAgentHeaders, request } from "../request"; export interface OctopTimezoneSettings { timezone: string; @@ -49,7 +49,10 @@ export interface CaptchaSettingsPut { export const octopSettingsApi = { timezone: () => request("/settings/timezone"), upload: () => request("/settings/upload"), - hitl: () => request("/settings/hitl"), + hitl: (agentId?: string | null) => + request("/settings/hitl", { + headers: bridgeAgentHeaders(agentId), + }), capabilities: () => request("/settings/capabilities", { cache: "no-store", diff --git a/dashboard/src/api/request.ts b/dashboard/src/api/request.ts index c401161c9..7afb709d7 100644 --- a/dashboard/src/api/request.ts +++ b/dashboard/src/api/request.ts @@ -302,6 +302,9 @@ function buildAuthHeaders(path: string): Record { let _redirectingToLogin = false; +/** 401 codes that mean this Octop session is no longer valid. */ +const SESSION_UNAUTHORIZED_CODES = new Set(["AUTH_FAILED", "TOKEN_EXPIRED"]); + /** * Send the user to the login screen exactly once, no matter how many parallel * requests report an expired session. @@ -330,6 +333,18 @@ function handleUnauthorized(): void { * Handle 401 responses: clear token, redirect to login, and throw. * Shared by request(), requestBlob(), and requestUpload(). */ +async function errorCodeOf(response: Response): Promise { + try { + const body = (await response.clone().json()) as { + error?: { code?: string }; + }; + const code = body?.error?.code; + return typeof code === "string" && code.trim() ? code : undefined; + } catch { + return undefined; + } +} + async function throwIfUnauthorized( path: string, response: Response, @@ -337,6 +352,15 @@ async function throwIfUnauthorized( if (response.status !== 401 || path.startsWith("/auth/")) { return; } + const code = await errorCodeOf(response); + // Business 401s (peer login, etc.) are not a dead local session. + if ( + code && + !SESSION_UNAUTHORIZED_CODES.has(code) && + !path.startsWith("/setup/") + ) { + return; + } clearAuthToken(); handleUnauthorized(); let message = "Unauthorized"; diff --git a/dashboard/src/api/request.unauthorized.test.ts b/dashboard/src/api/request.unauthorized.test.ts index 303d47cff..94ee6585a 100644 --- a/dashboard/src/api/request.unauthorized.test.ts +++ b/dashboard/src/api/request.unauthorized.test.ts @@ -55,6 +55,36 @@ describe("401 handling", () => { expect(replace).toHaveBeenCalledWith("/login"); }); + it("keeps the local session when a 401 is a peer credential error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve( + new Response( + JSON.stringify({ + error: { + code: "BRIDGE_AUTH_FAILED", + message: "peer rejected credentials", + details: {}, + }, + }), + { + status: 401, + headers: { "content-type": "application/json" }, + }, + ), + ), + ), + ); + + await expect( + mod.request("/bridge/probe", { method: "POST" }), + ).rejects.toThrow(/BRIDGE_AUTH_FAILED/); + + expect(replace).not.toHaveBeenCalled(); + expect(mod.getAuthToken()).toBe("expired-token"); + }); + it("redirects only once when several requests fail concurrently", async () => { const listener = vi.fn((event: Event) => event.preventDefault()); window.addEventListener(mod.UNAUTHORIZED_EVENT, listener); diff --git a/dashboard/src/components/CopyableResourceId.tsx b/dashboard/src/components/CopyableResourceId.tsx index 193e2dfe3..fda4a5395 100644 --- a/dashboard/src/components/CopyableResourceId.tsx +++ b/dashboard/src/components/CopyableResourceId.tsx @@ -14,6 +14,8 @@ type CopyableResourceIdProps = { className?: string; /** Match surrounding meta text (e.g. creator line). */ inline?: boolean; + /** Inline: show only the value, without the `label:` prefix. */ + valueOnly?: boolean; }; export function CopyableResourceId({ @@ -22,6 +24,7 @@ export function CopyableResourceId({ copyTitle, className, inline = false, + valueOnly = false, }: CopyableResourceIdProps) { const { t } = useTranslation(); @@ -41,7 +44,7 @@ export function CopyableResourceId({ onClick={() => void copy()} > - {inline ? `${label}: ${value}` : label} + {inline ? (valueOnly ? value : `${label}: ${value}`) : label} {!inline ? {value} : null} - - - ); + /** + * ``center`` straddles the divider (folder-tree toggle). + * ``before`` / ``after`` sit on either side when two rails share one line. + */ + placement?: "center" | "before" | "after"; } /** - * Hover-revealed collapse/expand control anchored to a vertical rail line. + * Collapse/expand control anchored to a vertical rail line. + * Matches the knowledge-base folder tree edge button. */ export default function RailEdgeControl({ expanded, @@ -56,6 +37,7 @@ export default function RailEdgeControl({ label, className, showLine = true, + placement = "center", }: RailEdgeControlProps) { const { t } = useTranslation(); const ariaLabel = @@ -66,21 +48,34 @@ export default function RailEdgeControl({ return (

{showLine ?
: null}
); diff --git a/dashboard/src/hooks/useHitlEnabled.test.ts b/dashboard/src/hooks/useHitlEnabled.test.ts index 87a887db9..fbc441ce7 100644 --- a/dashboard/src/hooks/useHitlEnabled.test.ts +++ b/dashboard/src/hooks/useHitlEnabled.test.ts @@ -52,4 +52,21 @@ describe("useHitlEnabled", () => { await waitFor(() => expect(hitl).toHaveBeenCalled()); expect(hook.result.current).toBe(false); }); + + it("reloads peer HITL settings when the remote agent changes", async () => { + hitl.mockImplementation(async (agentId?: string | null) => ({ + enabled: agentId === "bridge:cid:host", + tool_guard_require_approval: false, + show_approval_ui: agentId === "bridge:cid:host", + })); + const hook = renderHook( + ({ agentId }: { agentId?: string | null }) => useHitlEnabled(agentId), + { initialProps: { agentId: "local-host" } }, + ); + await waitFor(() => expect(hitl).toHaveBeenCalledWith("local-host")); + expect(hook.result.current).toBe(false); + hook.rerender({ agentId: "bridge:cid:host" }); + await waitFor(() => expect(hook.result.current).toBe(true)); + expect(hitl).toHaveBeenCalledWith("bridge:cid:host"); + }); }); diff --git a/dashboard/src/hooks/useHitlEnabled.ts b/dashboard/src/hooks/useHitlEnabled.ts index 8278fabb0..0f4f224b7 100644 --- a/dashboard/src/hooks/useHitlEnabled.ts +++ b/dashboard/src/hooks/useHitlEnabled.ts @@ -2,13 +2,13 @@ import { useEffect, useState } from "react"; import { octopSettingsApi } from "../api/modules/settings"; /** Whether tool-approval UI should show (HITL or command-guard require_approval). */ -export function useHitlEnabled(): boolean { +export function useHitlEnabled(agentId?: string | null): boolean { const [enabled, setEnabled] = useState(false); useEffect(() => { let cancelled = false; void octopSettingsApi - .hitl() + .hitl(agentId) .then((data) => { if (!cancelled) setEnabled(Boolean(data.show_approval_ui)); }) @@ -18,7 +18,7 @@ export function useHitlEnabled(): boolean { return () => { cancelled = true; }; - }, []); + }, [agentId]); return enabled; } diff --git a/dashboard/src/layouts/MainLayout/index.tsx b/dashboard/src/layouts/MainLayout/index.tsx index dbf53d737..6ade91092 100644 --- a/dashboard/src/layouts/MainLayout/index.tsx +++ b/dashboard/src/layouts/MainLayout/index.tsx @@ -229,6 +229,11 @@ export default function MainLayout() { expanded={!collapsed} onToggle={handleNavRailToggle} side="end" + placement={ + isChatRoute && !isMinimalLayout && !chatSidebarOpen + ? "before" + : "center" + } /> )}
diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index f83de26df..f80a7e99f 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -308,6 +308,7 @@ }, "skillPackages": { "title": "Skill Packages", + "packageNav": "Package navigation", "subtitle": "Create and maintain reusable skill packages, add skills, then mount them on experts", "createPackage": "Create Skill Package", "editPackage": "Edit Skill Package", @@ -423,6 +424,11 @@ "sharedBadge": "Shared", "sharedHint": "Can be on together with Default enabled. Everyone signed in to this instance can read and select it; only the owner can edit it. Default-open applies only to the owner.", "documents": "Documents", + "docTree": "File navigation", + "folderTreeTitle": "Directory navigation", + "showDocTree": "Show file navigation", + "hideDocTree": "Hide file navigation", + "resizeDocTree": "Resize file navigation", "documentLimit": "{{count}} / {{max}} documents", "documentLimitReached": "This knowledge base already contains the maximum of {{count}} documents.", "maxDocuments": "Document limit", @@ -1470,6 +1476,14 @@ "createSessionFailed": "Failed to create a session — please try again", "searchSessions": "Search experts or conversations", "noSearchResults": "No matching conversations", + "navScopeExperts": "Experts", + "navScopeTeams": "Teams", + "navScopeCloud": "Cloud", + "navScopeEmptyExperts": "No experts yet", + "navScopeEmptyTeams": "No teams yet", + "navScopeEmptyCloud": "No cloud collab yet", + "navScopeCreateTeam": "Create your first team", + "navScopeCreateCloud": "Add cloud collab", "expandMore": "Show more", "pin": "Pin", "unpin": "Unpin", @@ -3604,7 +3618,13 @@ "passwordKeepHint": "Leave blank to keep the current password", "passwordKeepPlaceholder": "••••••••", "emptyTitle": "No cloud collaborations yet", - "emptyDesc": "Add a peer Octop address with account and password to start.", + "emptyDesc": "Link this Octop to another instance and use that side's experts and teams from here.", + "emptyGuideStepWhat": "What is cloud collab?", + "emptyGuideStepWhatDetail": "A connection links this Octop to another instance. You can browse that side's experts and teams and start chats here. History stays on the side where the expert lives.", + "emptyGuideStepHow": "How to add one", + "emptyGuideStepHowDetail": "Click Add connection and enter the peer address, username, and password. Probe first to confirm the account, then save and connect. Start the link from the side that can reach the other.", + "emptyGuideStepChat": "How to chat", + "emptyGuideStepChatDetail": "After it connects, remote experts and teams show up under Cloud in the chat sidebar. The Experts tab also lists remote experts, and the Teams tab lists remote teams.", "drawerHint": "The display name is used for chat group switching and must be unique. Probe first to verify the account; Save & connect only persists after login and the cloud-collab handshake succeed — a failure will not keep the name.", "directionHint": "Initiate from the side that can reach the other. Typically a LAN or laptop connects out to a public/cloud Octop. Probe only checks the account; the handshake must also succeed before the row is saved.", "sectionIdentity": "Display", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index b32307adb..623bd21f1 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -308,6 +308,7 @@ }, "skillPackages": { "title": "技能包", + "packageNav": "技能包导航", "subtitle": "创建和维护可复用的技能包,添加技能后挂载到专家", "createPackage": "创建技能包", "editPackage": "编辑技能包", @@ -423,6 +424,11 @@ "sharedBadge": "已共享", "sharedHint": "可与「是否默认启用」同时开启。共享后实例内所有登录用户可读、可选用;仅所有者可编辑。默认启用只对创建者生效。", "documents": "文档", + "docTree": "文件导航", + "folderTreeTitle": "目录导航", + "showDocTree": "显示文件导航", + "hideDocTree": "收起文件导航", + "resizeDocTree": "调整文件导航宽度", "documentLimit": "{{count}} / {{max}} 个文档", "documentLimitReached": "此知识库已达到 {{count}} 个文档的上限。", "maxDocuments": "文档数量上限", @@ -1470,6 +1476,14 @@ "createSessionFailed": "创建会话失败,请重试", "searchSessions": "搜索专家或会话", "noSearchResults": "没有匹配的会话", + "navScopeExperts": "专家", + "navScopeTeams": "团队", + "navScopeCloud": "云端", + "navScopeEmptyExperts": "还没有专家", + "navScopeEmptyTeams": "还没有团队", + "navScopeEmptyCloud": "还没有云端协同", + "navScopeCreateTeam": "创建第一个团队", + "navScopeCreateCloud": "添加云端协同", "expandMore": "展开更多", "pin": "置顶", "unpin": "取消置顶", @@ -3604,7 +3618,13 @@ "passwordKeepHint": "留空则保持原密码", "passwordKeepPlaceholder": "••••••••", "emptyTitle": "暂无云端协同", - "emptyDesc": "填写对端 Octop 地址、对端账号和密码后即可添加。", + "emptyDesc": "把本机连到另一台 Octop,就能在这边使用对端的专家和团队。", + "emptyGuideStepWhat": "云端协同是什么", + "emptyGuideStepWhatDetail": "一条连接把本机 Octop 接到另一台实例。可以浏览对端专家和团队,并在本机发起对话;对话记录留在专家所在的那一侧。", + "emptyGuideStepHow": "怎么添加", + "emptyGuideStepHowDetail": "点击「添加连接」,填写对端地址、账号和密码。建议先「探测」确认账号可用,再保存并连接。请从能访问到对端的一侧发起。", + "emptyGuideStepChat": "怎么聊天", + "emptyGuideStepChatDetail": "连接成功后,对端专家和团队会出现在聊天页的「云端」里。专家页签也会带上云端专家,团队页签会带上云端团队。", "drawerHint": "显示名用于聊天页分组切换(须唯一)。建议先「探测」确认账号可达;「保存并连接」仅在登录与云端协同握手都成功后才会落库,失败不会占用显示名。", "directionHint": "请从能访问到对端的一侧发起。通常由内网或本机去连公网/云端。探测成功只说明账号可用,还需要握手成功才会保存。", "sectionIdentity": "显示信息", diff --git a/dashboard/src/pages/Agent/Channels/ChannelsPanel.test.tsx b/dashboard/src/pages/Agent/Channels/ChannelsPanel.test.tsx index cd66d2220..9a3795765 100644 --- a/dashboard/src/pages/Agent/Channels/ChannelsPanel.test.tsx +++ b/dashboard/src/pages/Agent/Channels/ChannelsPanel.test.tsx @@ -162,6 +162,46 @@ describe(" create-flow default", () => { }); }); + it("renders QQ credentials before the group chat policy", async () => { + const row = { + id: "qq-channel-1", + kind: "qq", + name: "qq", + enabled: true, + }; + api.mockImplementation(async (url) => { + if (url.endsWith("/qq-channel-1")) { + return { + ...row, + config: { app_id: "1905353226", secret: "test-secret" }, + }; + } + return [row]; + }); + + render(); + await userEvent.click((await screen.findAllByText("channels.label_qq"))[0]); + + const channelType = await screen.findByText("channels.channelType"); + const channelId = screen.getByText("Channel ID"); + const appId = screen.getByText("App ID"); + const appSecret = screen.getByText("App Secret"); + const displaySettings = screen.getByText("channels.channelSettings"); + const groupPolicy = screen.getByText("channels.qqGroupPolicyTitle"); + const expectBefore = (first: Element, second: Element) => { + expect( + first.compareDocumentPosition(second) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }; + + expectBefore(channelType, channelId); + expectBefore(channelId, appId); + expectBefore(appId, appSecret); + expectBefore(appSecret, displaySettings); + expectBefore(displaySettings, groupPolicy); + }); + async function openTelegramCreateDrawer() { render(); // Telegram is collapsed behind "更多通道" until expanded. diff --git a/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx b/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx index 64cb5f56a..2d5aad102 100644 --- a/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx +++ b/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx @@ -1823,7 +1823,6 @@ export function ChannelDrawer({ - {selectedKind === "qq" && } {(configMode === "quick" || isQuickOnly) && supportsQuickConfig && @@ -1939,6 +1938,7 @@ export function ChannelDrawer({ )} + {selectedKind === "qq" && } )} diff --git a/dashboard/src/pages/Chat/chatMessages.partial.less b/dashboard/src/pages/Chat/chatMessages.partial.less index 7db798477..4c9a064ad 100644 --- a/dashboard/src/pages/Chat/chatMessages.partial.less +++ b/dashboard/src/pages/Chat/chatMessages.partial.less @@ -1105,9 +1105,16 @@ } .teamWrapupTurn { + .chatMsgColumns(); + row-gap: 0; margin-top: 6px; padding-top: 10px; border-top: 1px dashed var(--fn-border-secondary, rgba(0, 0, 0, 0.08)); + + > * { + grid-column: 1 / -1; + min-width: 0; + } } .teamWrapupBadge { @@ -1124,6 +1131,15 @@ letter-spacing: 0.02em; } +/* Host avatar is 36px and centered in the avatar slot. Start the label there. */ +.teamWrapupTurn > .teamWrapupBadge { + grid-column: 1 / 3; + justify-self: start; + width: max-content; + max-width: 100%; + margin-inline-start: calc((var(--chat-msg-avatar-slot, 48px) - 36px) / 2); +} + .knowledgeCitations { display: flex; flex-direction: column; diff --git a/dashboard/src/pages/Chat/chatThemeOverrides.partial.less b/dashboard/src/pages/Chat/chatThemeOverrides.partial.less index 428f0b3b7..4304a6ae9 100644 --- a/dashboard/src/pages/Chat/chatThemeOverrides.partial.less +++ b/dashboard/src/pages/Chat/chatThemeOverrides.partial.less @@ -124,6 +124,10 @@ .sessionSearchWrap { margin: 0 4px 8px; } + + .sessionScopeTabs { + margin: 0 4px 8px; + } } .minimalAgentSection { @@ -393,6 +397,49 @@ margin: 0 12px 10px 0; } +.sessionScopeTabs { + display: flex; + flex-shrink: 0; + gap: 2px; + margin: 0 12px 10px 0; + padding: 2px; + border-radius: 10px; + background: var(--fn-bg-secondary, rgba(0, 0, 0, 0.04)); +} + +.sessionScopeTab { + flex: 1; + min-width: 0; + height: 28px; + padding: 0 6px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--fn-text-secondary); + font-size: 12px; + line-height: 28px; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &:hover { + color: var(--fn-text-primary); + } + + &:focus-visible { + outline: 2px solid var(--fn-color-brand); + outline-offset: 1px; + } +} + +.sessionScopeTabActive { + background: var(--fn-bg-elevated, #fff); + color: var(--fn-text-primary); + font-weight: 600; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06); +} + .sessionSearchIcon { position: absolute; left: 10px; diff --git a/dashboard/src/pages/Chat/components/AssistantProcessSummary.test.tsx b/dashboard/src/pages/Chat/components/AssistantProcessSummary.test.tsx index 91d2ab252..1999b772c 100644 --- a/dashboard/src/pages/Chat/components/AssistantProcessSummary.test.tsx +++ b/dashboard/src/pages/Chat/components/AssistantProcessSummary.test.tsx @@ -187,6 +187,35 @@ describe("thinking display preference", () => { ); }); + it("keeps a decision row collapsed like other tools", () => { + const split: AssistantTurnSplit = { + tools: [], + thinkings: [], + processSteps: [ + { + kind: "decision", + notice: { + model: "laya", + empty: false, + parts: [ + { kind: "result", kept: [{ name: "可以收工", score: 0.9 }] }, + ], + }, + }, + ], + answerMessage: null, + }; + render(); + expect(screen.getByRole("button")).toHaveAttribute( + "aria-expanded", + "false", + ); + expect(screen.queryByText(/可以收工/)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText(/可以收工/)).toBeInTheDocument(); + }); + it("can restore automatic expansion from the menu", async () => { localStorage.setItem(COLLAPSE_THINKING_SOLO_KEY, "true"); render( diff --git a/dashboard/src/pages/Chat/components/AssistantProcessSummary.tsx b/dashboard/src/pages/Chat/components/AssistantProcessSummary.tsx index dce5ffda7..b634ea728 100644 --- a/dashboard/src/pages/Chat/components/AssistantProcessSummary.tsx +++ b/dashboard/src/pages/Chat/components/AssistantProcessSummary.tsx @@ -78,7 +78,7 @@ function decisionPartBody( /** Same chrome as a built-in tool row, for one decision-model pass. */ export function DecisionToolRow({ notice }: { notice: DecisionNotice }) { const { t } = useTranslation(); - const [expanded, setExpanded] = useState(true); + const [expanded, setExpanded] = useState(false); const title = decisionTitle(notice, t); const parts = notice.empty ? [] : notice.parts; return ( diff --git a/dashboard/src/pages/Chat/components/AssistantTurnView.hitl.test.tsx b/dashboard/src/pages/Chat/components/AssistantTurnView.hitl.test.tsx index 8513af522..af6f94c04 100644 --- a/dashboard/src/pages/Chat/components/AssistantTurnView.hitl.test.tsx +++ b/dashboard/src/pages/Chat/components/AssistantTurnView.hitl.test.tsx @@ -84,4 +84,34 @@ describe("AssistantTurnView approval", () => { expect(screen.getByText("磁盘还剩 8G")).toBeInTheDocument(); expect(screen.getByText("Confirm this action")).toBeInTheDocument(); }); + + it("keeps host wrap-up below the divider and leaves the decision collapsed", () => { + render( + , + ); + + const badge = screen.getByText("主持人总结"); + const answer = screen.getByText("今天就到这里"); + expect( + badge.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(screen.getByText(/决策模型/)).toBeInTheDocument(); + expect(screen.queryByText(/结果充分/)).not.toBeInTheDocument(); + }); }); diff --git a/dashboard/src/pages/Chat/components/AssistantTurnView.tsx b/dashboard/src/pages/Chat/components/AssistantTurnView.tsx index 97f8c2fcf..60e0d7aa4 100644 --- a/dashboard/src/pages/Chat/components/AssistantTurnView.tsx +++ b/dashboard/src/pages/Chat/components/AssistantTurnView.tsx @@ -265,76 +265,8 @@ export default function AssistantTurnView({ // would shift the layout before the avatar and the first status settle. if (!hasVisibleTurn) return null; - return ( -
- {todoAtTop ?
{todoAtTop}
: null} - {segmentProcess.map(({ split, hitl }, idx) => { - const showProcess = !compactProcess && hasProcessContent(split); - // Freeze process spinner while a pending approval card is open. - const processStreaming = - turnStreaming && - !hasPendingHitl && - idx === segmentProcess.length - 1 && - hitlLayout.trailingMessages.length === 0; - return ( -
- {showProcess ? ( - <> - - {todoPanel && idx === firstProcessSegmentIdx ? ( -
{todoPanel}
- ) : null} - - ) : null} - {split.answerMessage ? ( -
- -
- ) : null} - - -
- ); - })} + const trailingBody = ( + <> {showTrailingProcess ? ( <> )} {trailingSplit.answerMessage ? ( -
- {isWrapupTurn ? ( - - {t("chat.teamWrapupBadge", { defaultValue: "主持人总结" })} - - ) : null} +
)} + + ); + + return ( +
+ {todoAtTop ?
{todoAtTop}
: null} + {segmentProcess.map(({ split, hitl }, idx) => { + const showProcess = !compactProcess && hasProcessContent(split); + // Freeze process spinner while a pending approval card is open. + const processStreaming = + turnStreaming && + !hasPendingHitl && + idx === segmentProcess.length - 1 && + hitlLayout.trailingMessages.length === 0; + return ( +
+ {showProcess ? ( + <> + + {todoPanel && idx === firstProcessSegmentIdx ? ( +
{todoPanel}
+ ) : null} + + ) : null} + {split.answerMessage ? ( +
+ +
+ ) : null} + + +
+ ); + })} + {isWrapupTurn ? ( +
+ + {t("chat.teamWrapupBadge", { defaultValue: "主持人总结" })} + + {trailingBody} +
+ ) : ( + trailingBody + )}
); } diff --git a/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx b/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx index c16120958..b9c44ea3b 100644 --- a/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx +++ b/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx @@ -1131,6 +1131,7 @@ export default function ChatInputActionsRow({ threadId={threadId} selectedConnectors={selectedConnectors} isMobile={isMobile} + isStreaming={isStreaming} decisionSaved={decisionSaved} /> {/* Desktop: dedicated newChatBtn; mobile: replace polish with new-chat */} diff --git a/dashboard/src/pages/Chat/components/ChatNavScopeTabs.tsx b/dashboard/src/pages/Chat/components/ChatNavScopeTabs.tsx new file mode 100644 index 000000000..c7c1b3ab4 --- /dev/null +++ b/dashboard/src/pages/Chat/components/ChatNavScopeTabs.tsx @@ -0,0 +1,108 @@ +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { CHAT_NAV_SCOPES, type ChatNavScope } from "../utils/chatNavScope"; +import styles from "../index.module.less"; + +const NAV_SCOPE_LABELS: Record< + ChatNavScope, + { key: string; fallback: string; emptyKey: string; emptyFallback: string } +> = { + experts: { + key: "chat.navScopeExperts", + fallback: "专家", + emptyKey: "chat.navScopeEmptyExperts", + emptyFallback: "还没有专家", + }, + teams: { + key: "chat.navScopeTeams", + fallback: "团队", + emptyKey: "chat.navScopeEmptyTeams", + emptyFallback: "还没有团队", + }, + cloud: { + key: "chat.navScopeCloud", + fallback: "云端", + emptyKey: "chat.navScopeEmptyCloud", + emptyFallback: "还没有云端协同", + }, +}; + +export function chatNavScopeEmptyCopy(scope: ChatNavScope): { + key: string; + fallback: string; +} { + const meta = NAV_SCOPE_LABELS[scope]; + return { key: meta.emptyKey, fallback: meta.emptyFallback }; +} + +const NAV_SCOPE_CREATE: Partial< + Record +> = { + teams: { + key: "chat.navScopeCreateTeam", + fallback: "创建第一个团队", + to: "/experts?tab=teams", + }, + cloud: { + key: "chat.navScopeCreateCloud", + fallback: "添加云端协同", + to: "/bridge", + }, +}; + +/** Empty team / cloud list, with a link to create the first one. */ +export function ChatNavScopeEmpty({ scope }: { scope: ChatNavScope }) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const copy = chatNavScopeEmptyCopy(scope); + const action = NAV_SCOPE_CREATE[scope]; + return ( +
+

+ {t(copy.key, copy.fallback)} +

+ {action ? ( + + ) : null} +
+ ); +} + +/** Expert / team / cloud switch under the chat nav search box. */ +export default function ChatNavScopeTabs({ + value, + onChange, +}: { + value: ChatNavScope; + onChange: (scope: ChatNavScope) => void; +}) { + const { t } = useTranslation(); + return ( +
+ {CHAT_NAV_SCOPES.map((scope) => { + const meta = NAV_SCOPE_LABELS[scope]; + const selected = value === scope; + return ( + + ); + })} +
+ ); +} diff --git a/dashboard/src/pages/Chat/components/ChatSidebarPanel.tsx b/dashboard/src/pages/Chat/components/ChatSidebarPanel.tsx index 4c360261c..af9d488ae 100644 --- a/dashboard/src/pages/Chat/components/ChatSidebarPanel.tsx +++ b/dashboard/src/pages/Chat/components/ChatSidebarPanel.tsx @@ -180,6 +180,7 @@ export default function ChatSidebarPanel({ side={sidebarOpen ? "end" : "start"} /* Collapsed: share the nav rail divider — avoid a second gapped line. */ showLine={sidebarOpen} + placement={sidebarOpen ? "center" : "after"} className={ sidebarOpen ? styles.chatRailEdgeOpen : styles.chatRailEdgeClosed } diff --git a/dashboard/src/pages/Chat/components/ChatTitleBar.test.tsx b/dashboard/src/pages/Chat/components/ChatTitleBar.test.tsx index 78c462ed1..1868b6e17 100644 --- a/dashboard/src/pages/Chat/components/ChatTitleBar.test.tsx +++ b/dashboard/src/pages/Chat/components/ChatTitleBar.test.tsx @@ -65,6 +65,47 @@ describe("ChatTitleBar", () => { expect(screen.getByLabelText("chat.teamBadge")).toBeInTheDocument(); }); + it("shows the cloud flag next to a remote conversation title", () => { + render( + , + ); + + const flag = screen.getByLabelText(/Peer expert|对端专家|remoteExpert/); + expect(flag.querySelector("svg")).not.toBeNull(); + expect(screen.getByText("云端实验室")).toBeInTheDocument(); + }); + + it("keeps icon and fallback text when the connection name is empty", () => { + render( + , + ); + + const flag = screen.getByLabelText(/Peer expert|对端专家|remoteExpert/); + expect(flag.querySelector("svg")).not.toBeNull(); + expect(flag).toHaveTextContent(/对端|Peer|remoteExpert\.flag/); + }); + it("waits for the IME to finish before committing a rename", () => { const onRename = vi.fn(); render( diff --git a/dashboard/src/pages/Chat/components/ChatTitleBar.tsx b/dashboard/src/pages/Chat/components/ChatTitleBar.tsx index c9468f7d4..3af18af7b 100644 --- a/dashboard/src/pages/Chat/components/ChatTitleBar.tsx +++ b/dashboard/src/pages/Chat/components/ChatTitleBar.tsx @@ -14,6 +14,7 @@ import { confirmDeleteConversation } from "../utils/deleteConversation"; import type { Session } from "../hooks/useSessions"; import SessionChannelIcon from "./SessionChannelIcon"; import TeamChatBadge from "./TeamChatBadge"; +import RemoteExpertHint from "./RemoteExpertHint"; import styles from "../index.module.less"; import { DESKTOP_DRAG_REGION_CLASS } from "../../../utils/desktopChrome"; import { isImeComposing } from "../../../utils/ime"; @@ -29,6 +30,11 @@ interface ChatTitleBarProps { forkDisabled?: boolean; forkDisabledHint?: string; isTeam?: boolean; + agent?: { + bridge?: boolean | null; + bridge_connection_name?: string | null; + bridge_connection_icon?: string | null; + } | null; } export default function ChatTitleBar({ @@ -41,6 +47,7 @@ export default function ChatTitleBar({ forkDisabled, forkDisabledHint, isTeam = false, + agent = null, }: ChatTitleBarProps) { const { t } = useTranslation(); const [collapseThinking, setCollapseThinking] = useCollapseThinking(isTeam); @@ -160,6 +167,7 @@ export default function ChatTitleBar({ {title} + {session.pinned ? ( vi.fn()); + +vi.mock("../../../api/modules/octopThreads", () => ({ + octopThreadsApi: { + contextUsage: (...args: unknown[]) => contextUsage(...args), + }, +})); + +const HINT = 128_000; +const MAX = 128_000; + +function breakdown(used: number, max = MAX): ContextUsageBreakdown { + return { + max_tokens: max, + used_tokens: used, + segments: [{ key: "conversation", tokens: used }], + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function renderRing( + overrides: Partial> = {}, +) { + const props: ComponentProps = { + usedTokens: HINT, + maxTokens: MAX, + agentId: "agent-1", + threadId: "thread-1", + isStreaming: false, + ...overrides, + }; + return render(); +} + +function rerenderRing(view: ReturnType, isStreaming: boolean) { + return view.rerender( + , + ); +} + +function ringPct(): string | null { + return screen.getByRole("progressbar").getAttribute("aria-valuenow"); +} + +describe("ContextWindowRing after compaction", () => { + beforeEach(() => { + contextUsage.mockReset(); + }); + + it("drops to the server snapshot when a turn ends with less context", async () => { + // /compact shrinks the thread with no model call: hint stays pre-compaction. + contextUsage.mockResolvedValue(breakdown(70_400)); + const view = renderRing({ isStreaming: true }); + expect(ringPct()).toBe("100"); + + // Turn ended: refetch on the streaming→idle edge. + rerenderRing(view, false); + + await waitFor(() => expect(ringPct()).toBe("55")); + }); + + it("lets a newer stream hint win again after the snapshot", async () => { + contextUsage.mockResolvedValue(breakdown(70_400)); + const view = renderRing({ isStreaming: true }); + rerenderRing(view, false); + await waitFor(() => expect(ringPct()).toBe("55")); + + // A newer hint wins over the snapshot. + view.rerender( + , + ); + + await waitFor(() => expect(ringPct()).toBe("63")); + }); + + it("ignores a snapshot that resolves after a newer one", async () => { + const older = deferred(); + const newer = deferred(); + contextUsage + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(newer.promise); + + const view = renderRing({ isStreaming: true }); + // First turn ends, then a second begins and ends while the first is open. + rerenderRing(view, false); + rerenderRing(view, true); + rerenderRing(view, false); + await waitFor(() => expect(contextUsage).toHaveBeenCalledTimes(2)); + + newer.resolve(breakdown(70_400)); + await act(async () => { + await newer.promise; + }); + expect(ringPct()).toBe("55"); + + // The superseded response lands last and must not restore the old value. + older.resolve(breakdown(HINT)); + await act(async () => { + await older.promise; + }); + expect(ringPct()).toBe("55"); + }); + + it("refetches after a failed post-compaction refetch instead of reusing the cache", async () => { + contextUsage + .mockResolvedValueOnce(breakdown(HINT)) + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce(breakdown(70_400)); + + const view = renderRing({ isStreaming: true }); + + // Popover toggles on click: open, read the cached full snapshot, close. + fireEvent.click(screen.getByRole("progressbar")); + await waitFor(() => expect(contextUsage).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByRole("progressbar")); + + // Turn ended, but the refetch failed: the cached full snapshot is now + // untrustworthy, so the next open must go back to the server. + rerenderRing(view, false); + await waitFor(() => expect(contextUsage).toHaveBeenCalledTimes(2)); + + fireEvent.click(screen.getByRole("progressbar")); + await waitFor(() => expect(contextUsage).toHaveBeenCalledTimes(3)); + await waitFor(() => expect(ringPct()).toBe("55")); + }); +}); diff --git a/dashboard/src/pages/Chat/components/ContextWindowRing.tsx b/dashboard/src/pages/Chat/components/ContextWindowRing.tsx index 6626ebc91..b29bd6ef2 100644 --- a/dashboard/src/pages/Chat/components/ContextWindowRing.tsx +++ b/dashboard/src/pages/Chat/components/ContextWindowRing.tsx @@ -40,6 +40,8 @@ interface ContextWindowRingProps { threadId?: string | null; selectedConnectors?: string[]; isMobile?: boolean; + /** Live-turn flag: a true→false edge refetches the snapshot. */ + isStreaming: boolean; /** Session total omitted by the decision filter. Same figure as 本会话累计. */ decisionSaved?: number; } @@ -52,6 +54,7 @@ export default function ContextWindowRing({ threadId, selectedConnectors = [], isMobile = false, + isStreaming, decisionSaved = 0, }: ContextWindowRingProps) { const { t } = useTranslation(); @@ -60,14 +63,21 @@ export default function ContextWindowRing({ const [breakdown, setBreakdown] = useState( null, ); + // /compact lowers server usage with no model call, so the hint never drops on + // its own. Each snapshot records the hint it absorbed; this lives in state + // (not a ref) so the ring memo below recomputes when it changes. + const [breakdownHint, setBreakdownHint] = useState(null); const cacheRef = useRef<{ key: string; at: number; + hint: number; data: ContextUsageBreakdown; } | null>(null); // Stream hint — kept in a ref so prefetch does not re-fire on every token tick. const hintUsedRef = useRef(usedTokens ?? 0); hintUsedRef.current = usedTokens ?? 0; + // Request order decides which of two in-flight responses may land. + const requestSeqRef = useRef(0); const max = maxTokens > 0 ? maxTokens : DEFAULT_MAX; const hintUsed = usedTokens ?? 0; @@ -86,6 +96,7 @@ export default function ContextWindowRing({ const loadBreakdown = useCallback( async (opts?: { silent?: boolean; force?: boolean }) => { if (!agentId || !threadId || isPendingThread(threadId)) return; + const force = Boolean(opts?.force); const cached = cacheRef.current; const hint = hintUsedRef.current; // A new model call moves the live total. The 30s cache would otherwise @@ -95,15 +106,17 @@ export default function ContextWindowRing({ (cached?.data.used_tokens ?? 0) > 0 && Math.abs(hint - (cached?.data.used_tokens ?? 0)) >= 64; if ( - !opts?.force && + !force && !hintMoved && cached && cached.key === cacheKey && Date.now() - cached.at < BREAKDOWN_CACHE_TTL_MS ) { + setBreakdownHint(cached.hint); setBreakdown(cached.data); return; } + const reqSeq = ++requestSeqRef.current; if (!opts?.silent) setLoading(true); try { // Prefer a pure harness snapshot. Pass stream hint only as a fallback @@ -117,15 +130,25 @@ export default function ContextWindowRing({ inputTokens: !haveSegments && hint > 0 ? hint : undefined, mcpServers: selectedConnectors, }); + // A newer request, or a thread switch, already superseded this one. + if (reqSeq !== requestSeqRef.current) return; if (data.used_tokens > 0) { - cacheRef.current = { key: cacheKey, at: Date.now(), data }; + cacheRef.current = { key: cacheKey, at: Date.now(), hint, data }; + setBreakdownHint(hint); setBreakdown(data); } else { // Empty harness snapshot (old threads) must not pin the ring at 0%. cacheRef.current = null; + setBreakdownHint(null); setBreakdown(null); } } catch { + if (force) { + // A failed post-compaction refetch must not leave the pre-compaction + // snapshot cached; dropping it forces the next read to hit the server. + cacheRef.current = null; + setBreakdownHint(null); + } if (!opts?.silent) setBreakdown(null); } finally { if (!opts?.silent) setLoading(false); @@ -139,19 +162,36 @@ export default function ContextWindowRing({ useEffect(() => { setBreakdown(null); cacheRef.current = null; + setBreakdownHint(null); + // Invalidate anything still in flight for the previous key. + requestSeqRef.current += 1; }, [cacheKey]); + // Refetch on streaming→idle: the turn may have compacted server-side. + const wasStreamingRef = useRef(isStreaming); + useEffect(() => { + const wasStreaming = wasStreamingRef.current; + wasStreamingRef.current = isStreaming; + if (wasStreaming && !isStreaming) { + void loadBreakdown({ force: true, silent: true }); + } + }, [isStreaming, loadBreakdown]); + const ringUsed = useMemo(() => { - if (hintUsed > 0) return Math.min(hintUsed, max); - if (breakdown && breakdown.used_tokens > 0) { - const breakdownMax = - breakdown.max_tokens > 0 ? breakdown.max_tokens : max; + const hasBreakdown = breakdown != null && breakdown.used_tokens > 0; + const breakdownMax = + breakdown && breakdown.max_tokens > 0 ? breakdown.max_tokens : max; + // The snapshot is authoritative while the hint has not moved since it was + // fetched; a moved hint means the turn outran the snapshot. + if (hasBreakdown && hintUsed === breakdownHint) { return Math.min(breakdown.used_tokens, breakdownMax); } + if (hintUsed > 0) return Math.min(hintUsed, max); + if (hasBreakdown) return Math.min(breakdown.used_tokens, breakdownMax); // Until prefetch returns, show last-call hint (capped so a stale // turn-sum cannot flash a full ring). return 0; - }, [breakdown, hintUsed, max]); + }, [breakdown, breakdownHint, hintUsed, max]); const ringMax = breakdown && breakdown.max_tokens > 0 ? breakdown.max_tokens : max; diff --git a/dashboard/src/pages/Chat/components/MinimalAgentSessionNav.tsx b/dashboard/src/pages/Chat/components/MinimalAgentSessionNav.tsx index 53dd72135..12ea59a44 100644 --- a/dashboard/src/pages/Chat/components/MinimalAgentSessionNav.tsx +++ b/dashboard/src/pages/Chat/components/MinimalAgentSessionNav.tsx @@ -29,6 +29,12 @@ import { useExpertSessionCatalog } from "../hooks/useExpertSessionCatalog"; import { usePinnedExperts } from "../hooks/usePinnedExperts"; import { useSessionWorkIds } from "../hooks/useSessionWorkIds"; import { sortExpertsByPin } from "../utils/pinnedExpertsPrefs"; +import { + initialChatNavScope, + matchesChatNavScope, + type ChatNavScope, +} from "../utils/chatNavScope"; +import ChatNavScopeTabs, { ChatNavScopeEmpty } from "./ChatNavScopeTabs"; import { expertVisibleInNavSearch, matchesNavQuery, @@ -304,10 +310,18 @@ export default function MinimalAgentSessionNav({ ); const [searchQuery, setSearchQuery] = useState(""); + const [navScope, setNavScope] = useState(() => + initialChatNavScope( + agents.find((agent) => agent.agent_id === activeAgentId), + ), + ); const { pinnedIds, isPinned, togglePin } = usePinnedExperts(); const sortedAgents = useMemo( - () => sortExpertsByPin(agents, pinnedIds), - [agents, pinnedIds], + () => + sortExpertsByPin(agents, pinnedIds).filter((agent) => + matchesChatNavScope(agent, navScope), + ), + [agents, pinnedIds, navScope], ); const searching = searchQuery.trim().length > 0; const catalog = useExpertSessionCatalog(sortedAgents, searching); @@ -553,12 +567,17 @@ export default function MinimalAgentSessionNav({ aria-label={t("chat.searchSessions", "搜索会话")} />
- {searching && visibleAgents.length === 0 ? ( -
- {catalog.loading - ? t("common.loading") - : t("chat.noSearchResults", "没有匹配的会话")} -
+ + {visibleAgents.length === 0 ? ( + searching ? ( +
+ {catalog.loading + ? t("common.loading") + : t("chat.noSearchResults", "没有匹配的会话")} +
+ ) : ( + + ) ) : null} {visibleAgents.map((agent) => { const preview = byAgent[agent.agent_id] ?? []; diff --git a/dashboard/src/pages/Chat/components/RemoteExpertHint.tsx b/dashboard/src/pages/Chat/components/RemoteExpertHint.tsx index 93b53c728..3a006502f 100644 --- a/dashboard/src/pages/Chat/components/RemoteExpertHint.tsx +++ b/dashboard/src/pages/Chat/components/RemoteExpertHint.tsx @@ -35,7 +35,7 @@ export default function RemoteExpertHint({ const tip = name ? t("chat.remoteExpert.banner", { name }) : t("chat.remoteExpert.flag"); - const label = compact ? null : name || null; + const label = compact ? null : name || t("chat.remoteExpert.flag"); return ( & + Partial>, +): OctopAgent { + return { + description: null, + persona_mbti: null, + default_model: null, + system_prompt: null, + template_name: null, + state: "running", + last_error: null, + icon: null, + icon_name: null, + icon_url: null, + color: null, + config: {}, + ...partial, + }; +} + +const agents = [ + agent({ agent_id: "local-expert", name: "本地专家" }), + agent({ + agent_id: "bridge:c1:expert", + name: "云端专家", + bridge: true, + }), + agent({ agent_id: "local-team", name: "本地团队", kind: "team" }), + agent({ + agent_id: "bridge:c1:team", + name: "云端团队", + kind: "team", + bridge: true, + }), +]; + +function renderList() { + render( + + undefined} + onFetchAllSessions={() => undefined} + onSelect={() => undefined} + onAgentSelect={() => undefined} + onNewChat={() => undefined} + onDelete={() => undefined} + onRename={() => undefined} + onPin={() => undefined} + onFork={() => undefined} + /> + , + ); +} + +describe("MinimalAgentSessionNav nav scopes", () => { + beforeEach(() => { + vi.spyOn(octopThreadsApi, "list").mockResolvedValue([]); + }); + + it("filters the same expert, team, and cloud tabs", async () => { + const user = userEvent.setup(); + render( + + undefined} + onAgentSelect={() => undefined} + onNewChat={() => undefined} + onDeleteActive={() => undefined} + onRenameActive={() => undefined} + onPinActive={() => undefined} + onFork={() => undefined} + /> + , + ); + + expect(screen.getByText("本地专家")).toBeInTheDocument(); + expect(screen.getByText("云端专家")).toBeInTheDocument(); + expect(screen.queryByText("本地团队")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("tab", { name: "云端" })); + expect(screen.getByText("云端专家")).toBeInTheDocument(); + expect(screen.getByText("云端团队")).toBeInTheDocument(); + expect(screen.queryByText("本地专家")).not.toBeInTheDocument(); + expect(screen.queryByText("本地团队")).not.toBeInTheDocument(); + }); +}); + +function LocationProbe() { + const location = useLocation(); + return ( +
+ {location.pathname} + {location.search} +
+ ); +} + +describe("SessionList nav scopes", () => { + it("filters experts, teams, and cloud under the search box", async () => { + const user = userEvent.setup(); + renderList(); + + expect(screen.getByRole("tab", { name: "专家" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect(screen.getByText("本地专家")).toBeInTheDocument(); + expect(screen.getByText("云端专家")).toBeInTheDocument(); + expect(screen.queryByText("本地团队")).not.toBeInTheDocument(); + expect(screen.queryByText("云端团队")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("tab", { name: "团队" })); + expect(screen.getByText("本地团队")).toBeInTheDocument(); + expect(screen.getByText("云端团队")).toBeInTheDocument(); + expect(screen.queryByText("本地专家")).not.toBeInTheDocument(); + expect(screen.queryByText("云端专家")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("tab", { name: "云端" })); + expect(screen.getByText("云端专家")).toBeInTheDocument(); + expect(screen.getByText("云端团队")).toBeInTheDocument(); + expect(screen.queryByText("本地专家")).not.toBeInTheDocument(); + expect(screen.queryByText("本地团队")).not.toBeInTheDocument(); + }); + + it("links an empty team or cloud tab to the create page", async () => { + const user = userEvent.setup(); + render( + + + undefined} + onFetchAllSessions={() => undefined} + onSelect={() => undefined} + onAgentSelect={() => undefined} + onNewChat={() => undefined} + onDelete={() => undefined} + onRename={() => undefined} + onPin={() => undefined} + onFork={() => undefined} + /> + } + /> + } /> + } /> + + , + ); + + await user.click(screen.getByRole("tab", { name: "团队" })); + await user.click(screen.getByRole("button", { name: "创建第一个团队" })); + expect(screen.getByText("/experts?tab=teams")).toBeInTheDocument(); + }); +}); diff --git a/dashboard/src/pages/Chat/components/SessionList.tsx b/dashboard/src/pages/Chat/components/SessionList.tsx index 93d65ee51..2ca1970af 100644 --- a/dashboard/src/pages/Chat/components/SessionList.tsx +++ b/dashboard/src/pages/Chat/components/SessionList.tsx @@ -4,15 +4,15 @@ import { useNavigate } from "react-router-dom"; import { Dropdown } from "antd"; import type { MenuProps } from "antd"; import { - Pencil, + Eye, + EyeOff, + GitFork, MoreHorizontal, - Trash2, + Pencil, Pin, PinOff, Search, - GitFork, - Eye, - EyeOff, + Trash2, } from "lucide-react"; import type { Session } from "../hooks/useSessions"; import type { OctopAgent } from "../../../context/AgentContext"; @@ -39,6 +39,12 @@ import { matchesNavQuery, } from "../utils/navSessionSearch"; import { sortExpertsByPin } from "../utils/pinnedExpertsPrefs"; +import { + initialChatNavScope, + matchesChatNavScope, + type ChatNavScope, +} from "../utils/chatNavScope"; +import ChatNavScopeTabs, { ChatNavScopeEmpty } from "./ChatNavScopeTabs"; import ExpertNameLabel from "./ExpertNameLabel"; import ExpertNavActions from "./ExpertNavActions"; import { @@ -565,31 +571,46 @@ export default function SessionList({ const liveWorkingIds = useSessionWorkIds(); const [searchQuery, setSearchQuery] = useState(""); const [showingHidden, setShowingHidden] = useState(false); + const [navScope, setNavScope] = useState(() => + initialChatNavScope( + agents.find((agent) => agent.agent_id === activeAgentId), + ), + ); const { filterVisible, pickHidden, hide, unhide, canHide } = useHiddenSharedExperts(); const { pinnedIds, isPinned, togglePin } = usePinnedExperts(); const hiddenAgents = useMemo( - () => sortExpertsByPin(pickHidden(agents), pinnedIds), - [agents, pickHidden, pinnedIds], + () => + sortExpertsByPin(pickHidden(agents), pinnedIds).filter((agent) => + matchesChatNavScope(agent, navScope), + ), + [agents, pickHidden, pinnedIds, navScope], ); const sortedAgents = useMemo(() => { const visible = filterVisible(agents, { keepAgentIds: activeAgentId ? [activeAgentId] : [], }); - return sortExpertsByPin(visible, pinnedIds); - }, [agents, filterVisible, activeAgentId, pinnedIds]); + return sortExpertsByPin(visible, pinnedIds).filter((agent) => + matchesChatNavScope(agent, navScope), + ); + }, [agents, filterVisible, activeAgentId, pinnedIds, navScope]); // Leave the hidden-only view once nothing remains hidden. const viewingHidden = showingHidden && hiddenAgents.length > 0; const agentsToRender = viewingHidden ? hiddenAgents : sortedAgents; - const expandedAgentId = useMemo( - () => - viewingHidden ? null : activeAgentId ?? sortedAgents[0]?.agent_id ?? null, - [activeAgentId, sortedAgents, viewingHidden], - ); + const expandedAgentId = useMemo(() => { + if (viewingHidden) return null; + const activeInScope = sortedAgents.some( + (agent) => agent.agent_id === activeAgentId, + ); + if (activeInScope) return activeAgentId; + // Browsing another tab keeps the open chat; don't expand a stranger. + if (activeAgentId) return null; + return sortedAgents[0]?.agent_id ?? null; + }, [activeAgentId, sortedAgents, viewingHidden]); const searching = searchQuery.trim().length > 0; const fetchAllRequestedRef = useRef(false); useEffect(() => { @@ -692,6 +713,10 @@ export default function SessionList({
) : null} + {agents.length > 0 ? ( + + ) : null} + {agents.length === 0 ? (

@@ -721,11 +746,15 @@ export default function SessionList({ /> )) ) : listedAgents.length === 0 ? ( -

- {catalog.loading - ? t("common.loading") - : t("chat.noSearchResults", "没有匹配的会话")} -
+ searching || catalog.loading ? ( +
+ {catalog.loading + ? t("common.loading") + : t("chat.noSearchResults", "没有匹配的会话")} +
+ ) : ( + + ) ) : ( listedAgents.map((agent) => { const isActive = agent.agent_id === activeAgentId; diff --git a/dashboard/src/pages/Chat/hooks/chatStore.teamStream.test.ts b/dashboard/src/pages/Chat/hooks/chatStore.teamStream.test.ts index b6383010c..0802b61c9 100644 --- a/dashboard/src/pages/Chat/hooks/chatStore.teamStream.test.ts +++ b/dashboard/src/pages/Chat/hooks/chatStore.teamStream.test.ts @@ -701,6 +701,35 @@ describe("team member live stream", () => { expect(host[1]?.status).toBe("streaming"); }); + it("restores an in-progress member answer after refresh and keeps streaming", () => { + ingestHarnessChunk(SESSION, { + type: "token", + content: "hello ", + agent_id: "doctor", + team_snapshot: true, + team_live: true, + }); + let member = getSnapshot(SESSION).messages.filter( + (item) => item.speakerAgentId === "doctor", + ); + expect(member).toHaveLength(1); + expect(member[0]?.content).toBe("hello "); + expect(member[0]?.status).toBe("streaming"); + expect(getSnapshot(SESSION).liveSpeakers).toContain("doctor"); + + ingestHarnessChunk(SESSION, { + type: "token", + content: "doc", + agent_id: "doctor", + }); + member = getSnapshot(SESSION).messages.filter( + (item) => item.speakerAgentId === "doctor" && !item.toolData, + ); + expect(member).toHaveLength(1); + expect(member[0]?.content).toBe("hello doc"); + expect(member[0]?.status).toBe("streaming"); + }); + it("merges a member snapshot onto the live bubble", () => { ingestHarnessChunk(SESSION, { type: "token", diff --git a/dashboard/src/pages/Chat/hooks/chatStore.ts b/dashboard/src/pages/Chat/hooks/chatStore.ts index c220f2a57..2a0623def 100644 --- a/dashboard/src/pages/Chat/hooks/chatStore.ts +++ b/dashboard/src/pages/Chat/hooks/chatStore.ts @@ -1190,6 +1190,20 @@ export function setSessionTeamRoom(sessionId: string, isTeam: boolean): void { getOrCreate(sessionId).isTeamRoom = isTeam; } +/** Keep the dashboard socket after host ``turn.ended``. + * + * Team rooms need it for member replies. Bridge rooms need it even when the + * shadow agent is not flagged ``kind: team``, because closing the socket + * unlistens the peer before members flush. + */ +function keepRoomSocket( + state: SessionStreamState, + agentId?: string | null, +): boolean { + if (state.isTeamRoom) return true; + return Boolean(agentId?.startsWith("bridge:")); +} + function sessionHostAgentId( state: SessionStreamState, sessionId?: string, @@ -1244,16 +1258,19 @@ function handleHarnessChunk( switch (chunk.type) { case "token": { const snapshot = Boolean(chunk.team_snapshot); - // Snapshots are complete wall copies — never leave the speaker "live". - if (!snapshot) markSpeakerLive(state, speaker); + const liveCatchup = snapshot && Boolean(chunk.team_live); + // A finished snapshot is a complete wall copy. A live catchup is the + // text already spoken when the page refreshes; the member is still talking. + if (!snapshot || liveCatchup) markSpeakerLive(state, speaker); appendStreamingToken( state, chunk.content, speaker, snapshot, Boolean(chunk.team_wrapup), + liveCatchup, ); - if (snapshot) clearSpeakerLive(state, speaker); + if (snapshot && !liveCatchup) clearSpeakerLive(state, speaker); break; } case "reasoning": @@ -1598,6 +1615,7 @@ function appendStreamingToken( speakerAgentId?: string, snapshot = false, wrapup = false, + keepLive = false, ): void { if (!content) return; const speaker = speakerAgentId?.trim() || undefined; @@ -1621,7 +1639,7 @@ function appendStreamingToken( id: state.streamId, role: "assistant", content, - status: snapshot ? "done" : "streaming", + status: snapshot && !keepLive ? "done" : "streaming", timestamp: Date.now(), speakerAgentId: speaker, teamWrapup: true, @@ -1650,7 +1668,7 @@ function appendStreamingToken( id: generateId(), role: "assistant", content, - status: "done", + status: keepLive ? "streaming" : "done", timestamp: Date.now(), speakerAgentId: speaker, }, @@ -1662,10 +1680,11 @@ function appendStreamingToken( ? content : prev : content; - if (next !== prev || target.status !== "done") { + const snapStatus = keepLive ? "streaming" : "done"; + if (next !== prev || target.status !== snapStatus) { state.messages = [ ...state.messages.slice(0, idx), - { ...target, content: next, status: "done" }, + { ...target, content: next, status: snapStatus }, ...state.messages.slice(idx + 1), ]; } @@ -1679,7 +1698,7 @@ function appendStreamingToken( id: generateId(), role: "assistant", content, - status: "done", + status: keepLive ? "streaming" : "done", timestamp: Date.now(), speakerAgentId: speaker, }, @@ -2626,7 +2645,7 @@ export async function attachThread( ) { if (hitlKeepsSocket(data)) { parkLiveSocketForHitl(data, state, sessionId, live); - } else if (state.isTeamRoom) { + } else if (keepRoomSocket(state, agentId)) { live.listenOnly = true; finish("host"); } else { @@ -2647,7 +2666,7 @@ export async function attachThread( ) { if (hitlKeepsSocket(data)) { parkLiveSocketForHitl(data, state, sessionId, live); - } else if (state.isTeamRoom) { + } else if (keepRoomSocket(state, agentId)) { live.listenOnly = true; finish("host"); } else { @@ -2870,7 +2889,7 @@ async function sendTurnWebSocket( parkLiveSocketForHitl(data, state, sessionId, live); } else { controller.signal.removeEventListener("abort", onAbort); - if (state.isTeamRoom) { + if (keepRoomSocket(state, agentId)) { live.listenOnly = true; finish("host"); } else { @@ -2894,7 +2913,7 @@ async function sendTurnWebSocket( parkLiveSocketForHitl(data, state, sessionId, live); } else { controller.signal.removeEventListener("abort", onAbort); - if (state.isTeamRoom) { + if (keepRoomSocket(state, agentId)) { live.listenOnly = true; finish("host"); } else { @@ -2990,7 +3009,7 @@ async function sendTurnWebSocket( if ( settled && !userCancelled && - state.isTeamRoom && + keepRoomSocket(state, agentId) && resolvedThreadId && resolvedThreadId !== "__empty__" && !isLiveSocketOpen(sessionId) diff --git a/dashboard/src/pages/Chat/index.tsx b/dashboard/src/pages/Chat/index.tsx index 8945b886b..46f0b12a3 100644 --- a/dashboard/src/pages/Chat/index.tsx +++ b/dashboard/src/pages/Chat/index.tsx @@ -93,6 +93,7 @@ import { import ChatSidebarPanel from "./components/ChatSidebarPanel"; import ChatTitleBar from "./components/ChatTitleBar"; import TeamChatBadge from "./components/TeamChatBadge"; +import RemoteExpertHint from "./components/RemoteExpertHint"; import ChatComposerChrome from "./components/ChatComposerChrome"; import AskQuestionCard from "./components/AskQuestionCard"; import { @@ -133,7 +134,6 @@ function ChatPageInner() { threadId: threadId ?? null, }); const isMobile = useIsMobile(); - const hitlEnabled = useHitlEnabled(); const user = useCurrentUser(); const { layoutMode } = useLayoutMode(); const isMinimalLayout = layoutMode === "minimal"; @@ -203,6 +203,7 @@ function ChatPageInner() { loading: agentsLoading, } = useAgent(); const resolvedAgentId = routeAgentId || activeAgentId; + const hitlEnabled = useHitlEnabled(resolvedAgentId); const activeAgent = useMemo( () => agents.find((a) => a.agent_id === resolvedAgentId) ?? null, [agents, resolvedAgentId], @@ -1411,6 +1412,7 @@ function ChatPageInner() { {activeSessionTitle} +
)} {resolvedAgentId && !sharedExpertViewer && ( @@ -1476,6 +1478,7 @@ function ChatPageInner() { forkDisabled={sessionForkDisabled} forkDisabledHint={sessionForkDisabledHint} isTeam={isTeamChat} + agent={activeAgent} /> )} diff --git a/dashboard/src/pages/Chat/utils/chatNavScope.test.ts b/dashboard/src/pages/Chat/utils/chatNavScope.test.ts new file mode 100644 index 000000000..9cf4fe3e0 --- /dev/null +++ b/dashboard/src/pages/Chat/utils/chatNavScope.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { matchesChatNavScope } from "./chatNavScope"; + +const localExpert = { agent_id: "e1", kind: "expert" }; +const cloudExpert = { agent_id: "bridge:c1:e2", kind: "expert", bridge: true }; +const localTeam = { agent_id: "t1", kind: "team" }; +const cloudTeam = { agent_id: "bridge:c1:t2", kind: "team", bridge: true }; + +describe("matchesChatNavScope", () => { + it("keeps cloud experts on the expert tab and cloud teams on the team tab", () => { + expect(matchesChatNavScope(localExpert, "experts")).toBe(true); + expect(matchesChatNavScope(cloudExpert, "experts")).toBe(true); + expect(matchesChatNavScope(localTeam, "experts")).toBe(false); + expect(matchesChatNavScope(cloudTeam, "experts")).toBe(false); + + expect(matchesChatNavScope(localTeam, "teams")).toBe(true); + expect(matchesChatNavScope(cloudTeam, "teams")).toBe(true); + expect(matchesChatNavScope(localExpert, "teams")).toBe(false); + expect(matchesChatNavScope(cloudExpert, "teams")).toBe(false); + }); + + it("shows only remote experts and teams on the cloud tab", () => { + expect(matchesChatNavScope(cloudExpert, "cloud")).toBe(true); + expect(matchesChatNavScope(cloudTeam, "cloud")).toBe(true); + expect(matchesChatNavScope(localExpert, "cloud")).toBe(false); + expect(matchesChatNavScope(localTeam, "cloud")).toBe(false); + }); +}); diff --git a/dashboard/src/pages/Chat/utils/chatNavScope.ts b/dashboard/src/pages/Chat/utils/chatNavScope.ts new file mode 100644 index 000000000..4c9ff0b8c --- /dev/null +++ b/dashboard/src/pages/Chat/utils/chatNavScope.ts @@ -0,0 +1,37 @@ +import { isRemoteShadowAgent } from "../../../utils/remoteExpert"; +import { isTeamAgent } from "../../../utils/teamAgent"; + +/** Classic chat-nav filter under the search box. */ +export type ChatNavScope = "experts" | "teams" | "cloud"; + +export const CHAT_NAV_SCOPES: readonly ChatNavScope[] = [ + "experts", + "teams", + "cloud", +]; + +type NavAgent = { + kind?: string | null; + bridge?: boolean | null; + agent_id?: string | null; +}; + +/** Open the team tab when the current chat is a team; otherwise experts. */ +export function initialChatNavScope( + agent: { kind?: string | null } | null | undefined, +): ChatNavScope { + return agent && isTeamAgent(agent) ? "teams" : "experts"; +} + +/** + * Experts include cloud experts. Teams include cloud teams. + * Cloud is every remote shadow, expert or team. + */ +export function matchesChatNavScope( + agent: NavAgent, + scope: ChatNavScope, +): boolean { + if (scope === "cloud") return isRemoteShadowAgent(agent); + if (scope === "teams") return isTeamAgent(agent); + return !isTeamAgent(agent); +} diff --git a/dashboard/src/pages/Experts/index.tsx b/dashboard/src/pages/Experts/index.tsx index ab79f3a57..fb54f1d10 100644 --- a/dashboard/src/pages/Experts/index.tsx +++ b/dashboard/src/pages/Experts/index.tsx @@ -19,6 +19,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; import { Input, Spin, Tabs, Segmented, Tooltip } from "antd"; import { message } from "@/utils/antdMessage"; @@ -123,7 +124,19 @@ export default function ExpertsPage() { ); // ── Tab state ────────────────────────────────────────────────── - const [activeTab, setActiveTab] = useState("my"); + const [searchParams] = useSearchParams(); + const [activeTab, setActiveTab] = useState(() => { + const tab = searchParams.get("tab"); + if ( + tab === "my" || + tab === "teams" || + tab === "library" || + tab === "market" + ) { + return tab; + } + return "my"; + }); const { viewMode, setViewMode, showCardView } = useCardTableView( loadViewMode(), ); diff --git a/dashboard/src/pages/KnowledgeBases/index.module.less b/dashboard/src/pages/KnowledgeBases/index.module.less index f27b6218c..587b0ea7e 100644 --- a/dashboard/src/pages/KnowledgeBases/index.module.less +++ b/dashboard/src/pages/KnowledgeBases/index.module.less @@ -320,45 +320,6 @@ white-space: nowrap; } -.listPanelToggle, -.listPanelExpandBtn { - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - padding: 0; - border: none; - border-radius: 8px; - background: transparent; - color: var(--fn-text-tertiary); - cursor: pointer; - transition: - background 0.15s ease, - color 0.15s ease; - - &:hover { - background: var(--fn-bg-hover); - color: var(--fn-text-primary); - } -} - -.listPanelToggle { - width: 28px; - height: 28px; -} - -.listPanelExpandBtn { - position: absolute; - top: 16px; - left: 12px; - z-index: 1; - width: 32px; - height: 32px; - border: 1px solid var(--fn-border-secondary); - background: var(--fn-bg-container); - box-shadow: var(--fn-shadow-sm); -} - .listActions > :first-child { flex: 1; } @@ -392,7 +353,6 @@ flex-direction: column; width: 100%; box-sizing: border-box; - min-height: 116px; padding: 12px !important; border: 1px solid var(--fn-border-secondary) !important; border-radius: var(--fn-radius-md); @@ -429,19 +389,18 @@ .listName { display: flex; align-items: center; - gap: 10px; - overflow: hidden; + gap: 8px; + min-width: 0; color: var(--fn-text-primary); font-weight: 600; - text-overflow: ellipsis; - white-space: nowrap; } .listNameText { - flex: 1; + flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; } .listMoreBtn { @@ -494,15 +453,61 @@ -webkit-line-clamp: 2; } +.listId { + display: flex; + flex: 0 1 auto; + align-items: center; + min-width: 0; + max-width: 46%; + margin-left: auto; +} + +.listIdButton { + max-width: 100%; + border-radius: 4px; +} + +.listId .listIdButton { + padding: 0 2px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + letter-spacing: 0.02em; + + &:hover { + background: var(--fn-bg-hover); + } +} + .listMeta { display: flex; align-items: center; justify-content: space-between; flex-wrap: nowrap; gap: 8px; + min-width: 0; margin-top: auto; } +.listCreator { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + color: var(--fn-text-tertiary); + font-size: 12px; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.listMetaEnd { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: flex-end; + gap: 4px; + margin-left: auto; +} + .listCountTag { margin-inline-end: 0 !important; border-color: var(--fn-border-secondary); @@ -548,6 +553,46 @@ background: var(--fn-border-secondary); } +.splitDividerCollapsed { + width: 0; + margin: 0; + background: transparent; +} + +.listPanelEdgeToggle { + position: absolute; + // Match the folder-tree toggle, which is centered in the file pane + // below the detail padding, toolbar, and path row (16+44+30 = 90). + top: calc(50% + 45px); + left: 50%; + z-index: 3; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 44px; + padding: 0; + border: 1px solid var(--fn-border-secondary); + border-radius: 8px; + background: var(--fn-bg-elevated, var(--fn-bg-secondary)); + color: var(--fn-text-secondary); + box-shadow: 0 1px 4px + color-mix(in srgb, var(--fn-text-primary) 8%, transparent); + cursor: pointer; + transform: translate(-50%, -50%); + + &:hover { + border-color: var(--fn-border-primary); + background: var(--fn-bg-secondary); + color: var(--fn-text-primary); + } +} + +.splitDividerCollapsed .listPanelEdgeToggle { + left: 8px; + transform: translate(0, -50%); +} + .resizeHandle { position: absolute; inset: 0; @@ -587,10 +632,6 @@ padding: 16px 20px 0; } -.detailListCollapsed { - padding-left: 52px; -} - .detailLoading, .centered { display: flex; @@ -611,74 +652,13 @@ pointer-events: none; } -.detailHeader { - display: flex; - flex-direction: column; - gap: 4px; - flex-shrink: 0; - padding-bottom: 12px; - border-bottom: 1px solid var(--fn-border-secondary); -} - -.titleRow, -.titleGroup, -.sectionHeader { - display: flex; - align-items: center; - gap: 16px; -} - -.titleGroup { - justify-content: flex-start; - min-width: 0; - flex: 1; - gap: 4px; - flex-wrap: wrap; -} - -.detailTitle { - margin: 0 !important; - min-width: 0; - flex: 0 1 auto; -} - -.titleActions { +.toolbarStart { display: inline-flex; + flex: 1; align-items: center; - gap: 2px; - flex-shrink: 0; -} - -.titleActionBtn { - width: 28px; - height: 28px; - padding: 0; - color: var(--fn-text-tertiary); - - &:hover { - color: var(--fn-text-secondary) !important; - } -} - -.detailDescription { - margin: 0 !important; - max-width: 100%; -} - -.detailMeta { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px 16px; - margin-top: 4px; - max-width: 100%; -} - -.detailCreator { - margin: 0 !important; - max-width: 100%; - font-size: 12px; - line-height: 1.5715; + gap: 6px; + min-width: 0; + overflow: hidden; } .detailBody { @@ -687,9 +667,7 @@ flex: 1; flex-direction: column; min-height: 0; - overflow: auto; - /* Keep document cards clear of the vertical scrollbar. */ - padding-right: 12px; + overflow: hidden; } .detailBodyDropActive { @@ -721,7 +699,230 @@ } .docsToolbar { - margin: 12px 0 8px; + margin: 0 0 8px; +} + +.docsSplit { + display: flex; + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.docsSplitFramed { + border: 1px solid var(--fn-border-secondary); + border-radius: var(--fn-radius-lg); + background: var(--fn-bg-container); +} + +.docTreePane { + display: flex; + flex-direction: column; + flex-shrink: 0; + min-height: 0; + overflow: hidden; + background: color-mix( + in srgb, + var(--fn-bg-secondary) 88%, + var(--fn-bg-container) + ); +} + +.docTreeHeader { + box-sizing: border-box; + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + height: 38px; + margin-top: 12px; + padding: 0 12px; + border-bottom: 1px solid var(--fn-border-secondary); + color: var(--fn-text-secondary); + font-size: 14px; + font-weight: 600; + line-height: 22px; +} + +.docTreeScroll { + flex: 1; + min-height: 0; + overflow: auto; + padding: 8px 8px 12px; + + :global(.ant-tree) { + background: transparent; + font-size: 13px; + } + + :global(.ant-tree-switcher) { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + color: var(--fn-text-tertiary); + } + + :global(.ant-tree-treenode) { + align-items: center; + padding-bottom: 2px; + } + + :global(.ant-tree-indent-unit) { + width: 14px; + } + + :global(.ant-tree-node-content-wrapper) { + display: inline-flex; + align-items: center; + min-width: 0; + min-height: 28px; + padding-inline: 6px; + border-radius: 6px; + line-height: 28px; + transition: background 0.15s ease; + } + + :global(.ant-tree-node-content-wrapper:hover) { + background: var(--fn-bg-hover); + } + + :global(.ant-tree-node-content-wrapper.ant-tree-node-selected), + :global(.ant-tree-node-content-wrapper.ant-tree-node-selected:hover) { + background: color-mix( + in srgb, + var(--fn-color-brand) 12%, + transparent + ) !important; + color: var(--fn-text-primary); + } + + :global(.ant-tree-title) { + display: inline-flex; + max-width: 100%; + min-width: 0; + } +} + +.docTreeNode { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 100%; +} + +.docTreeName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.docTreeFolderIcon { + flex-shrink: 0; + color: #d97706; +} + +.docTreeDivider { + position: relative; + flex-shrink: 0; + align-self: stretch; + width: 12px; + margin: 0; + z-index: 2; + background: transparent; + + &::before { + content: ""; + position: absolute; + top: 10px; + bottom: 10px; + left: 50%; + width: 1px; + background: var(--fn-border-secondary); + transform: translateX(-50%); + } +} + +.docTreeDividerCollapsed { + width: 0; + background: transparent; + + &::before { + display: none; + } +} + +.docTreeResize { + position: absolute; + inset: 0; + z-index: 1; + cursor: col-resize; + + &::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 2px; + height: 36px; + border-radius: 2px; + background: var(--fn-border-secondary); + opacity: 0; + transform: translate(-50%, -50%); + transition: + opacity 0.15s, + background 0.15s; + } + + &:hover::after { + background: var(--fn-text-tertiary); + opacity: 1; + } +} + +.docTreeToggle { + position: absolute; + top: 50%; + left: 50%; + z-index: 3; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 44px; + padding: 0; + border: 1px solid var(--fn-border-secondary); + border-radius: 8px; + background: var(--fn-bg-elevated, var(--fn-bg-secondary)); + color: var(--fn-text-secondary); + box-shadow: 0 1px 4px + color-mix(in srgb, var(--fn-text-primary) 8%, transparent); + cursor: pointer; + transform: translate(-50%, -50%); + + &:hover { + border-color: var(--fn-border-primary); + background: var(--fn-bg-secondary); + color: var(--fn-text-primary); + } +} + +.docTreeDividerCollapsed .docTreeToggle { + left: 8px; + transform: translate(0, -50%); +} + +.docsMain { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: auto; + padding: 12px 14px 16px; + background: var(--fn-bg-container); } .docsPathRow { @@ -729,17 +930,20 @@ align-items: center; justify-content: space-between; gap: 12px 16px; - flex-wrap: wrap; - margin: 4px 0 8px; + flex-shrink: 0; + flex-wrap: nowrap; + min-width: 0; + margin: 0 0 8px; } .uploadHint { display: block; + flex: 1 1 auto; + min-width: 0; margin: 0 0 0 auto; font-size: 12px; line-height: 1.5; text-align: right; - flex: 1 1 auto; } .docsCount { @@ -1247,8 +1451,7 @@ flex-shrink: 0; width: 32px; height: 32px; - margin-top: 2px; - margin-right: 2px; + margin: 0; padding: 0; border: 0; border-radius: 8px; @@ -1277,10 +1480,6 @@ background: transparent; } - .layoutMobile .listItem { - min-height: 104px; - } - .layoutMobile .detail { flex: 1; min-height: 0; @@ -1290,28 +1489,25 @@ background: var(--fn-bg-container); } - .layoutMobile .detailHeader { - padding: 0 4px 12px; - } - - .layoutMobile .titleRow { - flex-wrap: wrap; - gap: 10px; - } - .layoutMobile .detailBody { padding: 0 4px; padding-right: 4px; } + + .layoutMobile .docsMain { + padding: 0; + background: transparent; + } } .pathBreadcrumb { display: flex; - flex-wrap: wrap; + flex: 1 1 auto; + flex-wrap: nowrap; align-items: center; - margin: 0; min-width: 0; - flex: 1 1 160px; + margin: 0; + overflow: hidden; font-size: 12px; font-family: var(--fn-font-mono, ui-monospace, monospace); } @@ -1322,6 +1518,21 @@ min-width: 0; } +.pathBreadcrumbIcon { + display: inline-flex; + flex-shrink: 0; + align-items: center; + margin-right: 6px; + color: var(--fn-text-tertiary); +} + +.pathBreadcrumbLabel { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .pathBreadcrumbSep { color: var(--fn-text-tertiary); padding: 0 2px; @@ -1329,16 +1540,17 @@ } .pathBreadcrumbLink { + display: inline-flex; + align-items: center; + min-width: 0; border: none; background: transparent; padding: 2px 4px; border-radius: 4px; color: var(--fn-text-secondary); cursor: pointer; - max-width: 140px; + max-width: 160px; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; font: inherit; &:hover { @@ -1348,12 +1560,13 @@ } .pathBreadcrumbCurrent { + display: inline-flex; + align-items: center; + min-width: 0; padding: 2px 4px; color: var(--fn-text-primary); - max-width: 160px; + max-width: 180px; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } .textEditorLoading { diff --git a/dashboard/src/pages/KnowledgeBases/index.tsx b/dashboard/src/pages/KnowledgeBases/index.tsx index 8aaedd4d0..44417e220 100644 --- a/dashboard/src/pages/KnowledgeBases/index.tsx +++ b/dashboard/src/pages/KnowledgeBases/index.tsx @@ -28,9 +28,10 @@ import { Switch, Tag, Tooltip, + Tree, Typography, } from "antd"; -import type { MenuProps } from "antd"; +import type { MenuProps, TreeDataNode } from "antd"; import type { InputRef } from "antd/es/input"; import { ResizableTable } from "@/components/ResizableTable"; import { @@ -50,14 +51,13 @@ import { MessageSquarePlus, Minimize2, MoreHorizontal, - PanelLeftClose, - PanelLeftOpen, Pencil, PencilLine, Plus, RefreshCw, Search, Settings, + Signpost, Trash2, X, } from "lucide-react"; @@ -105,11 +105,14 @@ import { setPendingAttachKnowledgeBaseId } from "../Chat/utils/pendingAttachKnow import skillStyles from "../Agent/Skills/index.module.less"; import { KNOWLEDGE_ICON_NAMES, knowledgeIconForName } from "./knowledgeIcons"; import { + buildKnowledgeFileTree, isDirectKnowledgeChild, joinKnowledgePath, + KNOWLEDGE_DOC_TREE_ROOT, knowledgeBasename, knowledgeBreadcrumb, shouldOpenKnowledgeFolder, + type KnowledgeFileTreeNode, } from "./knowledgeFolder"; import { resolveKnowledgeDeepLink } from "./knowledgeDeepLink"; import { @@ -196,6 +199,8 @@ function formatKnowledgeOwner( const FOLDER_ICON_COLOR = "#d97706"; +type DocTreeNode = TreeDataNode & { path: string; isDir: boolean }; + function DocumentFormatIcon({ filename, size = 14, @@ -301,6 +306,9 @@ export default function KnowledgeBasesPage() { selectedIdRef.current = selected?.id ?? null; const [documents, setDocuments] = useState([]); const [currentFolder, setCurrentFolder] = useState(""); + const [docTreeExpanded, setDocTreeExpanded] = useState([ + KNOWLEDGE_DOC_TREE_ROOT, + ]); const [folderModalOpen, setFolderModalOpen] = useState(false); const [folderName, setFolderName] = useState(""); const [renameModalOpen, setRenameModalOpen] = useState(false); @@ -689,6 +697,17 @@ export default function KnowledgeBasesPage() { }); const { collapsed: listPanelCollapsed, toggle: toggleListPanel } = useListPanelCollapsed("octop:knowledge-bases:list-collapsed"); + const { collapsed: docTreeCollapsed, toggle: toggleDocTree } = + useListPanelCollapsed("octop:knowledge-bases:doc-tree-collapsed", { + defaultCollapsed: true, + }); + const { size: docTreeWidth, onResizeStart: onDocTreeResizeStart } = + useHorizontalResize({ + min: 180, + max: 420, + defaultSize: 220, + storageKey: "octop:knowledge-bases:doc-tree-width", + }); const canManageSelected = Boolean( selected && canManageKnowledgeBase(selected, user), @@ -725,8 +744,41 @@ export default function KnowledgeBasesPage() { }); const folderCrumbs = knowledgeBreadcrumb( currentFolder, - t("knowledgeBases.documents"), + selected?.name || t("knowledgeBases.documents"), ); + const docTreeData = useMemo(() => { + const toAntd = (node: KnowledgeFileTreeNode): DocTreeNode => ({ + key: node.path, + path: node.path, + isDir: true, + isLeaf: !node.children?.length, + title: ( + + + + {node.name} + + + ), + children: node.children?.length ? node.children.map(toAntd) : undefined, + }); + const root: DocTreeNode = { + key: KNOWLEDGE_DOC_TREE_ROOT, + path: "", + isDir: true, + isLeaf: false, + title: ( + + + + {t("knowledgeBases.documents")} + + + ), + children: buildKnowledgeFileTree(documents).map(toAntd), + }; + return [root]; + }, [documents, t]); const loadBases = useCallback(async () => { try { @@ -936,8 +988,24 @@ export default function KnowledgeBasesPage() { useEffect(() => { setCurrentFolder(""); + setDocTreeExpanded([KNOWLEDGE_DOC_TREE_ROOT]); }, [selected?.id]); + useEffect(() => { + setDocTreeExpanded((prev) => { + const keys = new Set(prev); + keys.add(KNOWLEDGE_DOC_TREE_ROOT); + let acc = ""; + for (const part of currentFolder.split("/").filter(Boolean)) { + acc = acc ? `${acc}/${part}` : part; + keys.add(acc); + } + const next = [...keys]; + if (next.every((key) => prev.includes(key))) return prev; + return next; + }); + }, [currentFolder]); + useEffect(() => { void Promise.all([loadBases(), loadCapability()]).finally(() => setLoading(false), @@ -2008,18 +2076,6 @@ export default function KnowledgeBasesPage() { {t("knowledgeBases.title")} - {!isMobile ? ( - - - - ) : null}
-
- {base.description || - t("knowledgeBases.noDescription")} -
+ +
+ {base.description || + t("knowledgeBases.noDescription")} +
+
- - {t("knowledgeBases.documentCount", { - count: base.doc_count, + - {base.default_open || base.shared ? ( -
- {base.default_open ? ( - - {t("knowledgeBases.defaultOpenBadge")} - - ) : null} - {base.shared ? ( - - {t("knowledgeBases.sharedBadge")} - - ) : null} -
- ) : null} + > + {t("knowledgeBases.createdBy", { + name: formatKnowledgeOwner(base), + })} +
+
+ + {t("knowledgeBases.documentCount", { + count: base.doc_count, + })} + + {base.default_open || base.shared ? ( +
+ {base.default_open ? ( + + {t("knowledgeBases.defaultOpenBadge")} + + ) : null} + {base.shared ? ( + + {t("knowledgeBases.sharedBadge")} + + ) : null} +
+ ) : null} +
@@ -2125,37 +2216,54 @@ export default function KnowledgeBasesPage() { )} ) : null} - {!isMobile && !listPanelCollapsed ? ( -
-
-
- ) : null} - {showDetailPane ? ( -
- {!isMobile && listPanelCollapsed ? ( - - - + {!listPanelCollapsed ? ( +
) : null} + + + +
+ ) : null} + {showDetailPane ? ( +
{detailLoading ? (
@@ -2169,155 +2277,44 @@ export default function KnowledgeBasesPage() {

) : !selected ? null : ( - <> -
-
-
- {isMobile ? ( - - ) : null} - - {selected.name} - -
-
- - {selected.description || - t("knowledgeBases.noDescription")} - -
- - - {t("knowledgeBases.createdBy", { - name: formatKnowledgeOwner(selected), - })} - +
+ {dragActive && canDropUpload ? ( +
+ + {t("knowledgeBases.dropToUpload")}
-
- + ) : null}
- {dragActive && canDropUpload ? ( -
- - {t("knowledgeBases.dropToUpload")} -
- ) : null} -
- - {documentCap === null - ? t("knowledgeBases.documentCount", { - count: fileCount, - }) - : t("knowledgeBases.documentLimit", { - count: fileCount, - max: documentCap, - })} - -
- - - {t("knowledgeBases.viewCard")} - - ), - }, - { - value: "table", - label: ( - - - {t("knowledgeBases.viewTable")} - - ), - }, - ]} - /> - - void uploadDocuments(event.target.files) - } - /> - {canWriteSelected ? ( - - ) : null} - {canWriteSelected ? ( - - ) : null} - {canWriteSelected ? ( - - ) : null} -
-
-
+
+ {isMobile ? ( + + ) : null} +
+
+ + + {t("knowledgeBases.viewCard")} + + ), + }, + { + value: "table", + label: ( + + + {t("knowledgeBases.viewTable")} + + ), + }, + ]} + /> + + void uploadDocuments(event.target.files) + } + /> {canWriteSelected ? ( - } + onClick={() => { + setFolderName(""); + setFolderModalOpen(true); + }} > - {t( - capability?.ocr?.usable - ? "knowledgeBases.uploadHintOcr" - : "knowledgeBases.uploadHint", - { - sizeMb: Math.round( - limits.max_document_bytes / (1024 * 1024), - ), - }, - )} - + {t("knowledgeBases.createFolder")} + + ) : null} + {canWriteSelected ? ( + + ) : null} + {canWriteSelected ? ( + ) : null}
- {uploadingThisBase && uploadState ? ( -
- +
+ + {documentCap === null + ? t("knowledgeBases.documentCount", { + count: fileCount, + }) + : t("knowledgeBases.documentLimit", { + count: fileCount, + max: documentCap, + })} + + {canWriteSelected ? ( + + {t( + capability?.ocr?.usable + ? "knowledgeBases.uploadHintOcr" + : "knowledgeBases.uploadHint", + { + sizeMb: Math.round( + limits.max_document_bytes / (1024 * 1024), + ), + }, + )} + + ) : null} +
+
+ {!isMobile && !docTreeCollapsed ? ( + + ) : null} + {!isMobile ? ( +
+ {!docTreeCollapsed ? ( +
+ ) : null} + - {uploadState.total > 1 - ? t("knowledgeBases.uploadingMany", { - name: uploadState.filename, - index: uploadState.index, - total: uploadState.total, - }) - : t("knowledgeBases.uploading", { - name: uploadState.filename, - })} - - + +
) : null} - {isAtDocumentLimit ? ( - - ) : null} - {folderEntries.length === 0 ? ( - - ) : showCardView ? ( -
- {folderEntries.map((document) => { - const name = knowledgeBasename( - document.path || document.filename, - ); - return ( -
{ - if ( - !shouldOpenKnowledgeFolder( - Boolean(document.is_dir), - event, - ) - ) { - return; +
+ {uploadingThisBase && uploadState ? ( +
+ + {uploadState.total > 1 + ? t("knowledgeBases.uploadingMany", { + name: uploadState.filename, + index: uploadState.index, + total: uploadState.total, + }) + : t("knowledgeBases.uploading", { + name: uploadState.filename, + })} + + +
+ ) : null} + {isAtDocumentLimit ? ( + + ) : null} + {folderEntries.length === 0 ? ( + + ) : showCardView ? ( +
+ {folderEntries.map((document) => { + const name = knowledgeBasename( + document.path || document.filename, + ); + return ( +
{ + if ( + !shouldOpenKnowledgeFolder( + Boolean(document.is_dir), + event, + ) + ) { + return; + } + setCurrentFolder( + document.path || document.filename, + ); + }} + > +
+ +
+
+
+ {name} +
+ {!document.is_dir && + fileExtensionLabel(name) ? ( + + {fileExtensionLabel(name)} + + ) : null} +
+
+ {document.is_dir + ? t("knowledgeBases.folder") + : `${formatBytes( + document.byte_size, + )} · ${t( + "knowledgeBases.chunkCount", + { + count: document.chunk_count, + }, + )}`} +
+
+ event.stopPropagation()} + > + {renderDocumentActions(document)} + +
+ {document.is_dir ? null : ( +
+ + + {t( + `knowledgeBases.statusesShort.${document.status}`, + )} + + + + {formatServerDateTime( + document.updated_at, + timeZone, + )} + +
+ )} +
+ ); + })} +
+ ) : ( + ({ + onClick: document.is_dir + ? (event) => { + if (!shouldOpenKnowledgeFolder(true, event)) { + return; + } + setCurrentFolder( + document.path || document.filename, + ); } - setCurrentFolder( + : undefined, + })} + locale={{ + emptyText: t("knowledgeBases.emptyDocuments"), + }} + columns={[ + { + title: t("knowledgeBases.filename"), + key: "filename", + ellipsis: true, + render: (_, document) => { + const name = knowledgeBasename( document.path || document.filename, ); - }} - > -
- -
-
-
+ return ( + + + {name} -
+ {!document.is_dir && fileExtensionLabel(name) ? ( {fileExtensionLabel(name)} ) : null} -
-
- {document.is_dir - ? t("knowledgeBases.folder") - : `${formatBytes( - document.byte_size, - )} · ${t("knowledgeBases.chunkCount", { - count: document.chunk_count, - })}`} -
-
- event.stopPropagation()} - > - {renderDocumentActions(document)} - -
- {document.is_dir ? null : ( -
+ + ); + }, + }, + { + title: t("knowledgeBases.entryType"), + key: "entry_type", + width: 88, + render: (_, document) => + document.is_dir + ? t("knowledgeBases.folder") + : t("knowledgeBases.file"), + }, + { + title: t("knowledgeBases.status"), + key: "status", + width: 100, + render: (_, document) => + document.is_dir ? ( + "—" + ) : ( - - {formatServerDateTime( - document.updated_at, - timeZone, - )} - -
- )} -
- ); - })} -
- ) : ( - ({ - onClick: document.is_dir - ? (event) => { - if (!shouldOpenKnowledgeFolder(true, event)) { - return; - } - setCurrentFolder( - document.path || document.filename, - ); - } - : undefined, - })} - locale={{ - emptyText: t("knowledgeBases.emptyDocuments"), - }} - columns={[ - { - title: t("knowledgeBases.filename"), - key: "filename", - ellipsis: true, - render: (_, document) => { - const name = knowledgeBasename( - document.path || document.filename, - ); - return ( - - - - {name} - - {!document.is_dir && - fileExtensionLabel(name) ? ( - - {fileExtensionLabel(name)} - - ) : null} - - ); + ), }, - }, - { - title: t("knowledgeBases.entryType"), - key: "entry_type", - width: 88, - render: (_, document) => - document.is_dir - ? t("knowledgeBases.folder") - : t("knowledgeBases.file"), - }, - { - title: t("knowledgeBases.status"), - key: "status", - width: 100, - render: (_, document) => - document.is_dir ? ( - "—" - ) : ( - + document.is_dir ? "—" : chunkCount, + }, + { + title: t("knowledgeBases.updatedAt"), + dataIndex: "updated_at", + key: "updated_at", + width: 170, + render: (updatedAt: number) => + formatServerDateTime(updatedAt, timeZone), + }, + { + title: t("common.actions"), + key: "actions", + width: canWriteSelected ? 120 : 48, + render: (_, document) => ( +
event.stopPropagation()} > - - {t( - `knowledgeBases.statusesShort.${document.status}`, - )} - - + {renderDocumentActions(document)} +
), - }, - { - title: t("knowledgeBases.chunks"), - dataIndex: "chunk_count", - key: "chunk_count", - width: 80, - render: (chunkCount: number, document) => - document.is_dir ? "—" : chunkCount, - }, - { - title: t("knowledgeBases.updatedAt"), - dataIndex: "updated_at", - key: "updated_at", - width: 170, - render: (updatedAt: number) => - formatServerDateTime(updatedAt, timeZone), - }, - { - title: t("common.actions"), - key: "actions", - width: canWriteSelected ? 120 : 48, - render: (_, document) => ( -
event.stopPropagation()} - > - {renderDocumentActions(document)} -
- ), - }, - ]} - /> - )} + }, + ]} + /> + )} +
- +
)}
) : null} diff --git a/dashboard/src/pages/KnowledgeBases/knowledgeFolder.test.ts b/dashboard/src/pages/KnowledgeBases/knowledgeFolder.test.ts index 65ad52316..d37e25ae7 100644 --- a/dashboard/src/pages/KnowledgeBases/knowledgeFolder.test.ts +++ b/dashboard/src/pages/KnowledgeBases/knowledgeFolder.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + buildKnowledgeFileTree, isDirectKnowledgeChild, joinKnowledgePath, knowledgeBasename, @@ -23,6 +24,32 @@ describe("knowledgeFolder", () => { expect(isDirectKnowledgeChild("notes/deep/file.md", "notes")).toBe(false); }); + it("builds a folder tree and fills missing parents", () => { + expect( + buildKnowledgeFileTree([ + { filename: "readme.md", path: "readme.md" }, + { filename: "law.md", path: "notes/law/law.md" }, + { filename: "notes", path: "notes", is_dir: true }, + ]), + ).toEqual([ + { + key: "notes", + path: "notes", + name: "notes", + isDir: true, + children: [ + { + key: "notes/law", + path: "notes/law", + name: "law", + isDir: true, + children: [], + }, + ], + }, + ]); + }); + it("builds breadcrumb segments", () => { expect(knowledgeBreadcrumb("notes/law", "知识库")).toEqual([ { label: "知识库", path: "" }, diff --git a/dashboard/src/pages/KnowledgeBases/knowledgeFolder.ts b/dashboard/src/pages/KnowledgeBases/knowledgeFolder.ts index 2b8101225..8794c3101 100644 --- a/dashboard/src/pages/KnowledgeBases/knowledgeFolder.ts +++ b/dashboard/src/pages/KnowledgeBases/knowledgeFolder.ts @@ -4,6 +4,11 @@ * keeps DOM-aware folder-open gating for the KB page. */ +import { + knowledgePathParent, + normalizeKnowledgePath, +} from "../../utils/knowledgePath"; + export { isDirectKnowledgeChild, joinKnowledgePath, @@ -24,3 +29,90 @@ export function shouldOpenKnowledgeFolder( } return true; } + +/** Sentinel key for the synthetic root row in the document tree. */ +export const KNOWLEDGE_DOC_TREE_ROOT = "__kb_root__"; + +export type KnowledgeFileTreeNode = { + key: string; + path: string; + name: string; + isDir: boolean; + children?: KnowledgeFileTreeNode[]; +}; + +type KnowledgeTreeSource = { + path?: string | null; + filename: string; + is_dir?: boolean; +}; + +export function knowledgeDocumentPath(document: KnowledgeTreeSource): string { + try { + return normalizeKnowledgePath(document.path || document.filename); + } catch { + return ""; + } +} + +type MutableTreeNode = { + path: string; + name: string; + isDir: boolean; + children: Map; +}; + +function ensureKnowledgeFolder( + root: Map, + folderPath: string, +): Map { + if (!folderPath) return root; + let map = root; + let acc = ""; + for (const part of folderPath.split("/")) { + acc = acc ? `${acc}/${part}` : part; + let node = map.get(part); + if (!node) { + node = { path: acc, name: part, isDir: true, children: new Map() }; + map.set(part, node); + } else { + node.isDir = true; + } + map = node.children; + } + return map; +} + +function freezeKnowledgeTree( + map: Map, +): KnowledgeFileTreeNode[] { + return [...map.values()] + .sort((a, b) => { + const dirDelta = Number(b.isDir) - Number(a.isDir); + if (dirDelta !== 0) return dirDelta; + return a.name.localeCompare(b.name); + }) + .map((node) => ({ + key: node.path, + path: node.path, + name: node.name, + isDir: node.isDir, + children: node.isDir ? freezeKnowledgeTree(node.children) : undefined, + })); +} + +/** Folder tree only. Parent folders implied by a file path are included. */ +export function buildKnowledgeFileTree( + documents: KnowledgeTreeSource[], +): KnowledgeFileTreeNode[] { + const root = new Map(); + for (const document of documents) { + const path = knowledgeDocumentPath(document); + if (!path) continue; + ensureKnowledgeFolder( + root, + document.is_dir ? path : knowledgePathParent(path), + ); + } + return freezeKnowledgeTree(root); +} diff --git a/dashboard/src/pages/Settings/AdvancedSettings/BridgeSettings.module.less b/dashboard/src/pages/Settings/AdvancedSettings/BridgeSettings.module.less index a17a3792e..d513efe60 100644 --- a/dashboard/src/pages/Settings/AdvancedSettings/BridgeSettings.module.less +++ b/dashboard/src/pages/Settings/AdvancedSettings/BridgeSettings.module.less @@ -4,6 +4,10 @@ padding: 48px 0; } +.emptyGuide { + padding: 24px 0 8px; +} + .pageToolbar { display: flex; align-items: center; diff --git a/dashboard/src/pages/Settings/AdvancedSettings/BridgeSettings.tsx b/dashboard/src/pages/Settings/AdvancedSettings/BridgeSettings.tsx index 757994526..2448305c0 100644 --- a/dashboard/src/pages/Settings/AdvancedSettings/BridgeSettings.tsx +++ b/dashboard/src/pages/Settings/AdvancedSettings/BridgeSettings.tsx @@ -43,7 +43,8 @@ import { import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { EmptyState } from "../../../components/EmptyState"; +import { OctopEmptyMascot } from "../../../components/EmptyState"; +import StreamSetupGuide from "../../../components/StreamSetupGuide/StreamSetupGuide"; import { useAgent } from "../../../context/AgentContext"; import { useCardTableView } from "../../../hooks/useCardTableView"; import { apiErrorMessage } from "../../../utils/apiError"; @@ -1291,10 +1292,38 @@ export default function BridgeSettingsPanel({
) : rows.length === 0 ? ( - } title={t("advancedSettings.bridge.emptyTitle")} description={t("advancedSettings.bridge.emptyDesc")} + steps={[ + { + label: t("advancedSettings.bridge.emptyGuideStepWhat"), + detail: t("advancedSettings.bridge.emptyGuideStepWhatDetail"), + }, + { + label: t("advancedSettings.bridge.emptyGuideStepHow"), + detail: t("advancedSettings.bridge.emptyGuideStepHowDetail"), + }, + { + label: t("advancedSettings.bridge.emptyGuideStepChat"), + detail: t("advancedSettings.bridge.emptyGuideStepChatDetail"), + }, + ]} + primaryAction={{ + label: t("advancedSettings.bridge.add"), + onClick: () => setCreateOpen(true), + icon: , + disabled: atConnectionLimit, + title: atConnectionLimit + ? t("advancedSettings.bridge.addDisabled", { + limit: MAX_BRIDGE_CONNECTIONS, + }) + : undefined, + }} /> ) : ( <> diff --git a/dashboard/src/pages/SkillPackages/index.module.less b/dashboard/src/pages/SkillPackages/index.module.less index fd0877c7e..c20a027a9 100644 --- a/dashboard/src/pages/SkillPackages/index.module.less +++ b/dashboard/src/pages/SkillPackages/index.module.less @@ -42,45 +42,6 @@ white-space: nowrap; } -.listPanelToggle, -.listPanelExpandBtn { - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - padding: 0; - border: none; - border-radius: 8px; - background: transparent; - color: var(--fn-text-tertiary); - cursor: pointer; - transition: - background 0.15s ease, - color 0.15s ease; - - &:hover { - background: var(--fn-bg-hover); - color: var(--fn-text-primary); - } -} - -.listPanelToggle { - width: 28px; - height: 28px; -} - -.listPanelExpandBtn { - position: absolute; - top: 16px; - left: 12px; - z-index: 1; - width: 32px; - height: 32px; - border: 1px solid var(--fn-border-secondary); - background: var(--fn-bg-container); - box-shadow: var(--fn-shadow-sm); -} - .packageListActions { display: flex; gap: 8px; @@ -115,10 +76,10 @@ } .listItem { - display: block !important; + display: flex !important; + flex-direction: column; width: 100%; box-sizing: border-box; - min-height: 116px; padding: 12px !important; border: 1px solid var(--fn-border-secondary) !important; border-radius: var(--fn-radius-md); @@ -155,19 +116,18 @@ .listName { display: flex; align-items: center; - gap: 10px; - overflow: hidden; + gap: 8px; + min-width: 0; color: var(--fn-text-primary); font-weight: 600; - text-overflow: ellipsis; - white-space: nowrap; } .listNameText { - flex: 1; + flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; } .listMoreBtn { @@ -328,18 +288,46 @@ word-break: break-word; } +.listId { + display: flex; + flex: 0 1 auto; + align-items: center; + min-width: 0; + max-width: 46%; + margin-left: auto; +} + +.listIdButton { + max-width: 100%; + border-radius: 4px; +} + +.listId .listIdButton { + padding: 0 2px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + letter-spacing: 0.02em; + + &:hover { + background: var(--fn-bg-hover); + } +} + .listMeta { display: flex; justify-content: space-between; align-items: center; gap: 8px; + min-width: 0; margin-top: auto; } .listCreator { + flex: 1 1 auto; min-width: 0; color: var(--fn-text-tertiary); font-size: 12px; + line-height: 20px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -362,6 +350,44 @@ background: var(--fn-border-secondary); } +.splitDividerCollapsed { + width: 0; + margin: 0; + background: transparent; +} + +.listPanelEdgeToggle { + position: absolute; + top: 50%; + left: 50%; + z-index: 3; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 44px; + padding: 0; + border: 1px solid var(--fn-border-secondary); + border-radius: 8px; + background: var(--fn-bg-elevated, var(--fn-bg-secondary)); + color: var(--fn-text-secondary); + box-shadow: 0 1px 4px + color-mix(in srgb, var(--fn-text-primary) 8%, transparent); + cursor: pointer; + transform: translate(-50%, -50%); + + &:hover { + border-color: var(--fn-border-primary); + background: var(--fn-bg-secondary); + color: var(--fn-text-primary); + } +} + +.splitDividerCollapsed .listPanelEdgeToggle { + left: 8px; + transform: translate(0, -50%); +} + .resizeHandle { position: absolute; inset: 0; @@ -398,11 +424,8 @@ flex: 1; min-width: 0; overflow: hidden; - padding: 16px 20px 0; -} - -.detailListCollapsed { - padding-left: 52px; + // Extra left inset so the edge toggle does not sit against the skill list. + padding: 16px 20px 0 40px; } .detailLoadingOverlay { @@ -417,52 +440,43 @@ pointer-events: none; } -.detailHeader { - display: flex; - flex-direction: column; - gap: 4px; - flex-shrink: 0; - padding-bottom: 12px; - border-bottom: 1px solid var(--fn-border-secondary); -} - -.detailTitleRow { - display: flex; - align-items: center; - gap: 16px; -} - -.detailTitleGroup { - display: flex; +.toolbarStart { + display: inline-flex; + flex: 1; align-items: center; - gap: 4px; + gap: 10px; min-width: 0; - flex: 1; - flex-wrap: wrap; + overflow: hidden; } -.detailTitle { - margin: 0 !important; - min-width: 0; +.packageNav { + display: inline-flex; flex: 0 1 auto; + align-items: center; + min-width: 0; + overflow: hidden; + color: var(--fn-text-primary); + font-size: 12px; + font-family: var(--fn-font-mono, ui-monospace, monospace); } -.titleActions { +.packageNavIcon { display: inline-flex; - align-items: center; - gap: 2px; flex-shrink: 0; + align-items: center; + margin-right: 6px; + color: var(--fn-text-tertiary); } -.titleActionBtn { - width: 28px; - height: 28px; - padding: 0; - color: var(--fn-text-tertiary); +.packageNavLabel { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} - &:hover { - color: var(--fn-text-secondary) !important; - } +.skillCount { + flex-shrink: 0; } .mobileBackBtn { @@ -472,8 +486,7 @@ flex-shrink: 0; width: 32px; height: 32px; - margin-top: 2px; - margin-right: 2px; + margin: 0; padding: 0; border: none; border-radius: 8px; @@ -487,27 +500,6 @@ } } -.detailDescription { - margin: 0 !important; - max-width: 100%; -} - -.detailMeta { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px 16px; - margin-top: 4px; - max-width: 100%; -} - -.detailCreator { - margin: 0 !important; - max-width: 100%; - font-size: 12px; - line-height: 1.5715; -} - .actions { display: flex; flex-shrink: 0; @@ -526,7 +518,7 @@ } .skillsToolbar { - margin: 12px 0 16px; + margin: 0 0 16px; } .centered { @@ -583,10 +575,6 @@ background: transparent; } -.layoutMobile .listItem { - min-height: 104px; -} - .layoutMobile .detail { flex: 1; min-height: 0; @@ -596,15 +584,6 @@ border-radius: var(--fn-radius-lg); } -.layoutMobile .detailHeader { - padding: 0 4px 12px; -} - -.layoutMobile .detailTitleRow { - flex-wrap: wrap; - gap: 10px; -} - .layoutMobile .detailBody { padding: 0 4px; padding-right: 4px; diff --git a/dashboard/src/pages/SkillPackages/index.tsx b/dashboard/src/pages/SkillPackages/index.tsx index b7557c141..d74f66fca 100644 --- a/dashboard/src/pages/SkillPackages/index.tsx +++ b/dashboard/src/pages/SkillPackages/index.tsx @@ -26,16 +26,16 @@ import { message } from "@/utils/antdMessage"; import { ChevronLeft, + ChevronRight, Download, FileDown, LayoutGrid, List as ListIcon, MoreHorizontal, - PanelLeftClose, - PanelLeftOpen, Pencil, Plus, RefreshCw, + Signpost, Store, Trash2, } from "lucide-react"; @@ -660,18 +660,6 @@ export default function SkillPackagesPage() { {t("skillPackages.title")} - {!isMobile ? ( - - - - ) : null}
-
- {item.description || "—"} -
+ +
+ {item.description || "—"} +
+
- + {t("skillPackages.createdBy", { name: formatPackageCreator(item), })} @@ -768,38 +781,55 @@ export default function SkillPackagesPage() { ) : null} - {!isMobile && !listPanelCollapsed ? ( -
-
+ {!isMobile ? ( +
+ {!listPanelCollapsed ? ( +
+ ) : null} + + +
) : null} {showDetailPane ? ( -
- {!isMobile && listPanelCollapsed ? ( - - - - ) : null} +
{detailLoading ? (
@@ -813,150 +843,133 @@ export default function SkillPackagesPage() {

) : !selected ? null : ( - <> -
-
-
- {isMobile ? ( - - ) : null} - +
+
+ {isMobile ? ( + + ) : null} + + + {selected.name} - -
-
- - {selected.description || t("skillPackages.noDescription")} - -
- - + + - {t("skillPackages.createdBy", { - name: formatPackageCreator(selected), - })} - -
-
- -
-
- {t("skills.totalCount", { count: skills.length })} -
- - setViewMode(value === "table" ? "table" : "card") - } - options={[ - { - value: "card", - label: ( - - - {t("experts.viewCard")} - - ), - }, - { - value: "table", - label: ( - - - {t("experts.viewTable")} - - ), - }, - ]} - /> - - - +
+
+ + setViewMode(value === "table" ? "table" : "card") + } + options={[ + { + value: "card", + label: ( + + + {t("experts.viewCard")} + + ), + }, + { + value: "table", + label: ( + + + {t("experts.viewTable")} + + ), + }, + ]} + /> + - {canMutate ? ( - <> - - - - - ) : null} -
-
-
- {skillsContent} + + + {canMutate ? ( + <> + + + + + ) : null}
- +
+ {skillsContent} +
+
)}
) : null} diff --git a/dashboard/src/utils/parseHarnessChunk.ts b/dashboard/src/utils/parseHarnessChunk.ts index b66e1927c..a289ac467 100644 --- a/dashboard/src/utils/parseHarnessChunk.ts +++ b/dashboard/src/utils/parseHarnessChunk.ts @@ -20,6 +20,8 @@ export interface TokenChunk { agent?: string; /** Full member turn replay — replace/merge instead of appending fragments. */ team_snapshot?: boolean; + /** Snapshot of a member who is still speaking. Keep the bubble streaming. */ + team_live?: boolean; /** Host wrap-up after members finish — start a new bubble, do not continue dispatch. */ team_wrapup?: boolean; } @@ -189,6 +191,7 @@ export function parseHarnessChunk(line: string): HarnessChunk | null { content: typeof obj.content === "string" ? obj.content : "", agent_id: agentId, team_snapshot: obj.team_snapshot === true, + team_live: obj.team_live === true, team_wrapup: obj.team_wrapup === true, }; case "reasoning": diff --git a/dashboard/src/utils/teamAgent.ts b/dashboard/src/utils/teamAgent.ts index eae6f2391..f82d4f789 100644 --- a/dashboard/src/utils/teamAgent.ts +++ b/dashboard/src/utils/teamAgent.ts @@ -6,7 +6,7 @@ export const TEAM_AVATAR_URL = "/experts/avatars/team-host.svg"; /** True when this agent is a team host (group chat), not a solo expert. */ export function isTeamAgent( - agent: { kind?: string } | null | undefined, + agent: { kind?: string | null } | null | undefined, ): boolean { return agent?.kind === "team"; } diff --git a/fnos/docker/app/docker/docker-compose.yaml b/fnos/docker/app/docker/docker-compose.yaml index c33b4b119..b4eba9756 100644 --- a/fnos/docker/app/docker/docker-compose.yaml +++ b/fnos/docker/app/docker/docker-compose.yaml @@ -1,6 +1,6 @@ services: octop: - image: ghcr.io/tencentcloud/octop:1.0.2b9 + image: ghcr.io/tencentcloud/octop:1.0.2 container_name: octop restart: unless-stopped ulimits: diff --git a/fnos/docker/manifest b/fnos/docker/manifest index ef9d7f021..eaae8a27a 100644 --- a/fnos/docker/manifest +++ b/fnos/docker/manifest @@ -1,5 +1,5 @@ appname=octop -version=1.0.2b9 +version=1.0.2 display_name=Octop desc=腾讯开源的多用户多智能体 AI 助手平台,支持 Web 控制台、CLI、IM 渠道与定时任务,数据全部本地存储。

安装后使用:
首次会从 ghcr.io 下载与本包版本对应的镜像(设备需能访问 GitHub Container Registry),请等到应用中心显示「运行中」再点「打开」,或访问 http://设备IP:8088,用安装向导里设置的账号登录。
忘记密码可到数据目录 octop-login.txt 查看备份(仅记录安装或应用「设置」改密;网页改密后不会自动更新)。

首次登录后:
请在控制台「设置 → 模型」配置 API Key,并按需配置 IM 渠道、远程桌面、浏览器等。

注意:Docker 版通过容器部署,会拉取 ghcr.io/tencentcloud/octop 上与本包版本相同的镜像。 source=thirdparty diff --git a/fnos/native/manifest b/fnos/native/manifest index e6c084578..4751cdd26 100644 --- a/fnos/native/manifest +++ b/fnos/native/manifest @@ -1,5 +1,5 @@ appname=octop-native -version=1.0.2b9 +version=1.0.2 display_name=Octop desc=腾讯开源的多用户多智能体 AI 助手平台,支持 Web 控制台、CLI、IM 渠道与定时任务,数据全部本地存储。

安装后使用:
请等到应用中心显示「运行中」再点「打开」,或访问 http://设备IP:8089,用安装向导里设置的用户名或邮箱登录。
忘记密码时打开飞牛「文件管理」,进入应用共享里的 Octop 数据目录,查看 octop-login.txt(仅备份安装或应用「设置」改密;网页改密后不会自动更新);也可在应用「设置」中改密。

命令行管理:
支持官方 octop CLI(octop provider list / agent list / user list / skills / backup 等,详见 octop --help)。

首次登录后:
请在控制台「设置 → 模型」配置 API Key,并按需配置 IM 渠道、远程桌面、浏览器等。

注意:本地版直接运行在飞牛主机上,安装前会自动关联安装「Python 3.12」开发工具。 source=thirdparty diff --git a/pyproject.toml b/pyproject.toml index 4875f23da..950ff7a61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "octop" -version = "1.0.2b9" +version = "1.0.2" description = "Smarter self-hosted AI assistant for multiple users and agents, built on octop-harness" readme = "README.md" license = { text = "MIT" } diff --git a/src/octop/__init__.py b/src/octop/__init__.py index 21f18541e..99339be24 100644 --- a/src/octop/__init__.py +++ b/src/octop/__init__.py @@ -4,7 +4,7 @@ from octop.infra.utils.httpx_proxy import install_httpx_cidr_no_proxy -__version__ = "1.0.2b9" +__version__ = "1.0.2" # httpx 0.28 treats NO_PROXY CIDR as an exact IP (see issue #1347). Patch once # at import so CLI, ``octop run``, and in-process harness clients all honor it. diff --git a/src/octop/api/bridge_peer.py b/src/octop/api/bridge_peer.py index 0156bd8fc..1cc347801 100644 --- a/src/octop/api/bridge_peer.py +++ b/src/octop/api/bridge_peer.py @@ -31,7 +31,11 @@ async def prepare_peer_dashboard_turn( payload: dict[str, Any], ws_connection_id: str, ) -> PreparedPeerTurn: - """Validate a peer ``turn.start`` and build the gateway inbound.""" + """Validate a peer ``turn.start`` and build the gateway inbound. + + When the room is already busy, admit a mid-turn steer and return + ``inbound=None`` so the peer does not enqueue a second turn. + """ frame = UserTurnWsFrame.model_validate(bridge_turn_ws_payload(payload)) turn = frame.to_turn_body() if not turn_has_content(turn): @@ -42,6 +46,13 @@ async def prepare_peer_dashboard_turn( user=user, turn=turn, ) + if await _admit_peer_steer( + server, + agent_id=agent_id, + thread_id=prepared.thread_id, + turn=turn, + ): + return PreparedPeerTurn(thread_id=prepared.thread_id, inbound=None) inbound = build_dashboard_inbound( agent_id=agent_id, user_id=user.id, @@ -51,3 +62,50 @@ async def prepare_peer_dashboard_turn( user_is_admin=bool(getattr(user, "is_admin", False)), ) return PreparedPeerTurn(thread_id=prepared.thread_id, inbound=inbound) + + +async def _admit_peer_steer( + server: Any, + *, + agent_id: str, + thread_id: str, + turn: Any, +) -> bool: + """Push steer.ack / queued onto the live turn subscriber when busy.""" + rt = getattr(server, "app_runtime", None) + gateway = getattr(rt, "gateway", None) if rt is not None else None + if gateway is None: + return False + from octop.api.routers.chat.ws import ( # noqa: PLC0415 + _maybe_admit_steer, + _plain_text_from_turn, + _steer_blocks_from_turn, + _thread_hitl_paused, + ) + + hub = gateway.ws_hub + delivery = turn.delivery or "auto" + # Team members speak after the host WS turn goes idle. A leftover steer + # coordinator row must not swallow the next user_turn as a queued insert. + if ( + delivery != "steer" + and not hub.is_turn_active(thread_id) + and not _thread_hitl_paused(gateway, thread_id, agent_id) + ): + return False + + async def send_via_hub(steer_frame: dict[str, Any]) -> None: + await hub.push_to_thread(thread_id, steer_frame) + + return await _maybe_admit_steer( + text=(turn.text or "").strip() or _plain_text_from_turn(turn), + thread_id=thread_id, + delivery=delivery, + client_pending_id=turn.client_pending_id, + expected_turn_id=turn.expected_turn_id, + steer=gateway.steer, + hub=hub, + send_frame=send_via_hub, + hitl_paused=_thread_hitl_paused(gateway, thread_id, agent_id), + content_blocks=_steer_blocks_from_turn(turn), + ) diff --git a/src/octop/api/middleware/bridge_proxy.py b/src/octop/api/middleware/bridge_proxy.py index 181731e5a..b54718004 100644 --- a/src/octop/api/middleware/bridge_proxy.py +++ b/src/octop/api/middleware/bridge_proxy.py @@ -64,6 +64,7 @@ def _is_header_tunneled_path(path: str) -> bool: "/api/knowledge-bases/capability", "/api/browser/env-status", "/api/browser/harness-sessions", + "/api/settings/hitl", }: return True return raw == "/api/acp" or raw.startswith("/api/acp/") diff --git a/src/octop/api/routers/chat/ws.py b/src/octop/api/routers/chat/ws.py index 79c4866d4..4b72b7a61 100644 --- a/src/octop/api/routers/chat/ws.py +++ b/src/octop/api/routers/chat/ws.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import contextlib import json import logging @@ -21,6 +22,7 @@ prepare_dashboard_turn, turn_has_content, ) +from octop.infra.agents.teams.team_manager import subscribe_with_member_catchup from octop.infra.bridge.ids import parse_bridge_agent_id from octop.infra.errors import OctopError from octop.infra.gateway.steer import SteerAdmitOk, SteerAdmitReject, SteerCoordinator @@ -111,7 +113,16 @@ async def send_frame(frame: dict[str, Any]) -> None: {"type": "error", "message": f"thread {thread_id!r} not found"}, ) continue - hub.subscribe(thread_id, connection_id) + + def _subscribe(tid: str = thread_id, cid: str = connection_id) -> None: + hub.subscribe(tid, cid) + + await subscribe_with_member_catchup( + gateway, + thread_id, + subscribe=_subscribe, + send=send_frame, + ) await send_frame( { "type": "turn_status", @@ -277,6 +288,133 @@ async def send_frame(frame: dict[str, Any]) -> None: json.dumps(frame, ensure_ascii=False, default=json_chunk_default), ) + listen_task: asyncio.Task[None] | None = None + listen_thread_id = "" + turn_tasks: set[asyncio.Task[None]] = set() + host_turn_task: asyncio.Task[None] | None = None + host_turn_ended = False + last_thread_id = "" + + async def stop_listen() -> None: + nonlocal listen_task + task = listen_task + listen_task = None + if task is None or task.done(): + return + task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await task + + def start_listen(thread_id: str) -> None: + nonlocal listen_task, listen_thread_id + tid = (thread_id or "").strip() + if not tid: + return + prev = listen_task + if prev is not None and not prev.done(): + prev.cancel() + + async def _run() -> None: + try: + await mgr.relay_user_listen( + connection_id=ref.connection_id, + owner_user_id=user.id, + remote_agent_id=ref.remote_agent_id, + thread_id=tid, + on_frame=send_frame, + ) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("bridge chat listen failed") + + listen_thread_id = tid + listen_task = asyncio.create_task(_run(), name=f"bridge-listen-{tid}") + if prev is not None and not prev.done(): + + async def _await_prev() -> None: + with contextlib.suppress(asyncio.CancelledError, Exception): + await prev + + asyncio.create_task(_await_prev(), name="bridge-listen-cancel") + + def ensure_listen(thread_id: str) -> None: + tid = (thread_id or "").strip() + if not tid: + return + if listen_task is not None and not listen_task.done() and listen_thread_id == tid: + return + start_listen(tid) + + async def run_turn(turn_payload: dict[str, Any], *, is_host: bool) -> None: + nonlocal last_thread_id, host_turn_ended + resolved_thread_id = str(turn_payload.get("thread_id") or "").strip() + + async def capture(frame: dict[str, Any]) -> None: + nonlocal resolved_thread_id, host_turn_ended + kind = str(frame.get("type") or "") + if kind == "thread": + tid = str(frame.get("thread_id") or "").strip() + if tid: + resolved_thread_id = tid + if is_host and kind == "turn.ended": + host_turn_ended = True + # Keep forwarding after host ``turn.ended``. Member tokens use + # this same subscriber; handing the room to a second listen + # drops them. + await send_frame(frame) + + try: + await mgr.relay_user_turn( + connection_id=ref.connection_id, + owner_user_id=user.id, + remote_agent_id=ref.remote_agent_id, + turn_payload=turn_payload, + on_frame=capture, + ) + except asyncio.CancelledError: + raise + except OctopError as exc: + await send_frame({"type": "error", "message": str(exc)}) + await send_frame({"type": "done"}) + except Exception: + logger.exception("bridge chat relay failed") + await send_frame({"type": "error", "message": "bridge turn failed"}) + await send_frame({"type": "done"}) + else: + # Peer closed the turn (older peer). Re-attach so a late member + # flush still has a room socket. + if is_host: + ensure_listen(resolved_thread_id or last_thread_id) + finally: + if resolved_thread_id: + last_thread_id = resolved_thread_id + + def spawn_turn(turn_payload: dict[str, Any], *, is_host: bool) -> asyncio.Task[None]: + task = asyncio.create_task( + run_turn(turn_payload, is_host=is_host), + name="bridge-chat-turn", + ) + turn_tasks.add(task) + + def _cleanup(done: asyncio.Task[None]) -> None: + turn_tasks.discard(done) + with contextlib.suppress(asyncio.CancelledError, Exception): + done.exception() + + task.add_done_callback(_cleanup) + return task + + async def release_held_host() -> None: + nonlocal host_turn_task + task = host_turn_task + if task is None or task.done() or not host_turn_ended: + return + host_turn_task = None + task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await task + try: while websocket.application_state == WebSocketState.CONNECTED: raw = await websocket.receive_text() @@ -291,18 +429,47 @@ async def send_frame(frame: dict[str, Any]) -> None: await send_frame({"type": "pong"}) continue if msg_type == "subscribe": - # History/live resume stays on the peer; acknowledge without local hub. thread_id = str(payload.get("thread_id") or "").strip() - await send_frame( - { - "type": "turn_status", - "thread_id": thread_id, - "active": False, - } - ) + last_thread_id = thread_id or last_thread_id + if host_turn_task is not None and not host_turn_task.done() and not host_turn_ended: + continue + await release_held_host() + await stop_listen() + start_listen(thread_id) continue if msg_type == "cancel": - await send_frame({"type": "error", "message": "cancel not supported on bridge yet"}) + thread_id = str(payload.get("thread_id") or last_thread_id).strip() + if not thread_id: + await send_frame({"type": "error", "message": "cancel requires thread_id"}) + continue + try: + await mgr.relay_turn_cancel( + connection_id=ref.connection_id, + owner_user_id=user.id, + remote_agent_id=ref.remote_agent_id, + thread_id=thread_id, + on_frame=send_frame, + ) + except OctopError as exc: + await send_frame({"type": "error", "message": str(exc)}) + except Exception: + logger.exception("bridge chat cancel failed") + await send_frame({"type": "error", "message": "bridge cancel failed"}) + continue + if msg_type == "steer_remove": + try: + await mgr.relay_turn_steer_remove( + connection_id=ref.connection_id, + owner_user_id=user.id, + remote_agent_id=ref.remote_agent_id, + payload=payload, + on_frame=send_frame, + ) + except OctopError as exc: + await send_frame({"type": "error", "message": str(exc)}) + except Exception: + logger.exception("bridge chat steer_remove failed") + await send_frame({"type": "error", "message": "bridge steer_remove failed"}) continue if msg_type != "user_turn": await send_frame({"type": "error", "message": f"unknown message type: {msg_type}"}) @@ -320,24 +487,29 @@ async def send_frame(frame: dict[str, Any]) -> None: continue turn_payload = frame.model_dump(exclude_none=True) turn_payload.pop("type", None) - try: - await mgr.relay_user_turn( - connection_id=ref.connection_id, - owner_user_id=user.id, - remote_agent_id=ref.remote_agent_id, - turn_payload=turn_payload, - on_frame=send_frame, - ) - except OctopError as exc: - await send_frame({"type": "error", "message": str(exc)}) - await send_frame({"type": "done"}) - except Exception: - logger.exception("bridge chat relay failed") - await send_frame({"type": "error", "message": "bridge turn failed"}) - await send_frame({"type": "done"}) + last_thread_id = str(turn.thread_id or last_thread_id).strip() + if host_turn_task is not None and not host_turn_task.done(): + if host_turn_ended: + await release_held_host() + else: + spawn_turn(turn_payload, is_host=False) + continue + # One room pipe: the turn subscriber stays up after host + # ``turn.ended`` and forwards member frames. A second listen + # would duplicate the host stream. + await stop_listen() + host_turn_ended = False + host_turn_task = spawn_turn(turn_payload, is_host=True) except WebSocketDisconnect: pass finally: + await stop_listen() + pending = list(turn_tasks) + for task in pending: + task.cancel() + for task in pending: + with contextlib.suppress(asyncio.CancelledError, Exception): + await task if websocket.application_state == WebSocketState.CONNECTED: with contextlib.suppress(Exception): await websocket.close() diff --git a/src/octop/infra/agents/teams/team_manager.py b/src/octop/infra/agents/teams/team_manager.py index cfb1559c2..3299ec3ab 100644 --- a/src/octop/infra/agents/teams/team_manager.py +++ b/src/octop/infra/agents/teams/team_manager.py @@ -16,7 +16,7 @@ import inspect import json import logging -from collections.abc import Callable, Sequence +from collections.abc import Awaitable, Callable, Sequence from dataclasses import fields from types import SimpleNamespace from typing import TYPE_CHECKING, Any @@ -113,6 +113,23 @@ def host_system_prompt(row: Any, user_repo: Any) -> str: return f"{custom}\n\n{briefing}".strip() if custom else briefing +async def subscribe_with_member_catchup( + gateway: Any, + room_thread_id: str, + *, + subscribe: Callable[[], None], + send: Callable[[dict[str, Any]], Awaitable[None]], +) -> None: + """Attach a room subscriber and replay an in-progress member answer.""" + processor = getattr(gateway, "_processor", None) + teams = getattr(processor, "teams", None) + deliver = getattr(teams, "deliver_live_member_catchup", None) + if not callable(deliver): + subscribe() + return + await deliver(room_thread_id, subscribe=subscribe, send=send) + + class TeamManager: """Octop-side team room. Harness still owns ``ask_agent`` / inbox.""" @@ -135,6 +152,10 @@ def __init__( self._hitl_waiters: dict[str, asyncio.Future[list[dict[str, Any]]]] = {} self._peer_prompts: dict[tuple[str, str], tuple[str, str]] = {} self._live_host_replies: set[str] = set() + # Room text a member has already pushed and not yet persisted. + # A refresh mid-answer has no history row; the new subscriber gets this. + self._live_member_text: dict[str, dict[str, str]] = {} + self._member_wall_lock = asyncio.Lock() def replace_thread_message_repo(self, repo: Any) -> None: self._thread_message_repo = repo @@ -508,16 +529,19 @@ async def emit(chunk: dict[str, Any]) -> None: ) raise finally: - await release_held_room() - done: dict[str, Any] = {"type": "done"} - if extra_stamp: - done = {**done, **extra_stamp} - await self._relay_chunk( - done, - room_thread_id=room_thread_id, - member_thread_id=member_tid, - speaker_id=speaker_id, - ) + try: + await release_held_room() + done: dict[str, Any] = {"type": "done"} + if extra_stamp: + done = {**done, **extra_stamp} + await self._relay_chunk( + done, + room_thread_id=room_thread_id, + member_thread_id=member_tid, + speaker_id=speaker_id, + ) + finally: + await self._clear_live_member(room_thread_id, speaker_id) stream_text = "".join(text_parts).strip() return { "messages": await self._peer_result_messages( @@ -993,6 +1017,64 @@ def _title_member_thread(self, call: PeerCall, thread_id: str, user_id: int) -> ) self._thread_registry.set_title_if_null(thread_id, title) + def _append_live_member(self, room_thread_id: str, speaker_id: str, piece: str) -> None: + room = room_thread_id.strip() + speaker = speaker_id.strip() + if not room or not speaker or not piece: + return + bucket = self._live_member_text.setdefault(room, {}) + bucket[speaker] = bucket.get(speaker, "") + piece + + async def _clear_live_member(self, room_thread_id: str, speaker_id: str) -> None: + room = room_thread_id.strip() + speaker = speaker_id.strip() + if not room or not speaker: + return + async with self._member_wall_lock: + bucket = self._live_member_text.get(room) + if not bucket: + return + bucket.pop(speaker, None) + if not bucket: + self._live_member_text.pop(room, None) + + def _live_member_frames(self, room_thread_id: str) -> list[dict[str, Any]]: + room = room_thread_id.strip() + bucket = self._live_member_text.get(room) or {} + frames: list[dict[str, Any]] = [] + for speaker, text in bucket.items(): + if not speaker or not text: + continue + frames.append( + { + "type": "token", + "content": text, + "agent_id": speaker, + "agent": speaker, + "team_snapshot": True, + "team_live": True, + "thread_id": room, + } + ) + return frames + + async def deliver_live_member_catchup( + self, + room_thread_id: str, + *, + subscribe: Callable[[], None], + send: Callable[[dict[str, Any]], Awaitable[None]], + ) -> None: + """Subscribe, then replay member text that is on the wall but not in history. + + The lock covers subscribe and the replay so a token cannot land twice: + once inside the snapshot and again as a live delta. + """ + async with self._member_wall_lock: + subscribe() + for frame in self._live_member_frames(room_thread_id): + await send(frame) + async def _relay_chunk( self, chunk: dict[str, Any], @@ -1006,9 +1088,13 @@ async def _relay_chunk( if hub is None: return stamped = stamp_stream_speaker(chunk, speaker_id, include_done=True) + piece = str(chunk.get("content") or "") if str(chunk.get("type") or "") == "token" else "" try: if include_room and room_thread_id: - await hub.push_to_thread(room_thread_id, stamped) + async with self._member_wall_lock: + await hub.push_to_thread(room_thread_id, stamped) + if piece: + self._append_live_member(room_thread_id, speaker_id, piece) if member_thread_id and member_thread_id != room_thread_id: await hub.push_to_thread(member_thread_id, chunk) except Exception: @@ -1030,8 +1116,12 @@ async def _push_room_frame( if hub is None or not room_thread_id: return stamped = stamp_stream_speaker(chunk, speaker_id, include_done=True) + piece = str(chunk.get("content") or "") if str(chunk.get("type") or "") == "token" else "" try: - await hub.push_to_thread(room_thread_id, stamped) + async with self._member_wall_lock: + await hub.push_to_thread(room_thread_id, stamped) + if piece: + self._append_live_member(room_thread_id, speaker_id, piece) except Exception: logger.warning( "failed to flush team room frame speaker=%s room=%s", diff --git a/src/octop/infra/backend/grep_safety.py b/src/octop/infra/backend/grep_safety.py index bb38aac84..283aea5f9 100644 --- a/src/octop/infra/backend/grep_safety.py +++ b/src/octop/infra/backend/grep_safety.py @@ -1,14 +1,21 @@ -"""Harden deepagents' Python grep fallback (issue #2101). - -``FilesystemBackend._python_search`` walks ``root_dir`` (often ``/``) and -reads files with unbounded ``readline()``. Pseudo-files such as -``/proc//pagemap`` report ``st_size == 0`` but yield unlimited content -with no newline, so one read can grow RSS until the host OOMs. - -This module replaces ``_python_search`` at process start: skip POSIX virtual -filesystems and read with per-line / per-file byte caps. ``LocalShellBackend`` -inherits the method, so a class-level patch covers agent tools and the -workspace API. deepagents itself is not modified. +"""Process-start patches for deepagents grep. + +Two separate paths, installed together: + +* Python fallback (issue #2101). ``_python_search`` walks ``root_dir`` (often + ``/``) and reads files with unbounded ``readline()``. Pseudo-files such as + ``/proc//pagemap`` report ``st_size == 0`` but yield unlimited content + with no newline, so one read can grow RSS until the host OOMs. The + replacement skips POSIX virtual filesystems and caps reads. It already + decodes file bytes as UTF-8. ``LocalShellBackend`` inherits the method, so a + class-level patch covers agent tools and the workspace API. +* ripgrep (issue #1959). When ``rg`` is installed, ``grep`` uses it first and + never reaches the Python fallback. ``_ripgrep_search`` runs ``rg --json`` + with ``text=True`` and no ``encoding``, so a Chinese Windows host decodes + that UTF-8 stream as cp936 and the turn dies. A ``subprocess`` stand-in + forces UTF-8 for ripgrep only. + +deepagents itself is not modified. """ from __future__ import annotations @@ -33,8 +40,10 @@ _READ_CHUNK_BYTES = 64 * 1024 _MAX_LINE_BYTES = 1024 * 1024 +_RIPGREP_NAMES = frozenset({"rg", "rg.exe"}) -_INSTALLED = False +_PYTHON_SEARCH_INSTALLED = False +_RIPGREP_UTF8_INSTALLED = False class _ReadStatus: @@ -281,17 +290,73 @@ def _file_errors_msg() -> str | None: _safe_python_search.__octop_safe_grep__ = True # type: ignore[attr-defined] -def install_safe_grep_fallback() -> None: - """Replace ``FilesystemBackend._python_search`` once per process.""" - global _INSTALLED - if _INSTALLED: +def _executable_name(exe: str) -> str: + """Final path segment, accepting both POSIX and Windows separators.""" + return exe.replace("\\", "/").rsplit("/", 1)[-1].lower() + + +def _command_is_ripgrep(command: object) -> bool: + if isinstance(command, (str, bytes)) or not isinstance(command, (list, tuple)): + return False + if not command: + return False + exe = command[0] + if isinstance(exe, bytes): + text = os.fsdecode(exe) + elif isinstance(exe, str): + text = exe + else: + return False + return _executable_name(text) in _RIPGREP_NAMES + + +class _RipgrepSubprocess: + """``subprocess`` stand-in used only by ``deepagents.backends.filesystem``.""" + + __slots__ = ("_real",) + + def __init__(self, real: Any) -> None: + self._real = real + + def __getattr__(self, name: str) -> Any: + return getattr(self._real, name) + + def Popen(self, *args: Any, **kwargs: Any) -> Any: + command = args[0] if args else kwargs.get("args") + text_mode = bool(kwargs.get("text") or kwargs.get("universal_newlines")) + if text_mode and _command_is_ripgrep(command): + kwargs.setdefault("encoding", "utf-8") + kwargs.setdefault("errors", "replace") + return self._real.Popen(*args, **kwargs) + + +def _install_python_search_fallback() -> None: + global _PYTHON_SEARCH_INSTALLED + if _PYTHON_SEARCH_INSTALLED: return from deepagents.backends.filesystem import FilesystemBackend current = FilesystemBackend._python_search - if getattr(current, "__octop_safe_grep__", False): - _INSTALLED = True + if not getattr(current, "__octop_safe_grep__", False): + FilesystemBackend._python_search = _safe_python_search # type: ignore[method-assign] + logger.info("installed safe python grep fallback (skip virtual fs, bound reads)") + _PYTHON_SEARCH_INSTALLED = True + + +def _install_ripgrep_utf8_decoding() -> None: + global _RIPGREP_UTF8_INSTALLED + if _RIPGREP_UTF8_INSTALLED: return - FilesystemBackend._python_search = _safe_python_search # type: ignore[method-assign] - _INSTALLED = True - logger.info("installed safe python grep fallback (skip virtual fs, bound reads)") + import deepagents.backends.filesystem as filesystem + + current = filesystem.subprocess # type: ignore[attr-defined] + if not isinstance(current, _RipgrepSubprocess): + filesystem.subprocess = _RipgrepSubprocess(current) # type: ignore[attr-defined, assignment] + logger.info("installed ripgrep utf-8 decoding (ignore host locale)") + _RIPGREP_UTF8_INSTALLED = True + + +def install_safe_grep_fallback() -> None: + """Install the Python-search and ripgrep patches once per process.""" + _install_python_search_fallback() + _install_ripgrep_utf8_decoding() diff --git a/src/octop/infra/backend/shell_output.py b/src/octop/infra/backend/shell_output.py new file mode 100644 index 000000000..461fd72b3 --- /dev/null +++ b/src/octop/infra/backend/shell_output.py @@ -0,0 +1,101 @@ +"""Decode host-shell output without trusting the locale (issue #1959). + +``HarnessLocalShellBackend`` and ``BubbledLocalShellBackend`` run commands with +``text=True`` and no ``encoding``. Python then decodes the pipe with +``locale.getpreferredencoding(False)`` — cp936 on a Chinese Windows host. +Tools such as Python, git, and ripgrep emit UTF-8. On Windows the mismatch +raises inside ``subprocess``'s reader thread, the exception is discarded, and +``execute`` reports ```` with exit code 0. + +``cmd`` builtins emit the console code page, so the bytes are not forced to +UTF-8. Try strict UTF-8, then the locale code page, then GBK, and only then +replacement characters. + +This is a process-start stand-in for harness builds that do not yet ship +``decode_child_output``. When that function is present the patch is skipped, +so a newer harness is not decoded twice. +""" + +from __future__ import annotations + +import importlib +import locale +import logging +import subprocess +from typing import Any + +logger = logging.getLogger(__name__) + +_SHELL_MODULES = ( + "octop_harness.backends.local_shell", + "octop_harness.backends.bwrap_shell", +) +_INSTALLED = False + + +def decode_child_output(data: bytes | str | None) -> str: + """Decode one captured stream. Never raises.""" + if not data: + return "" + if isinstance(data, str): + return data + seen: set[str] = set() + for encoding in ("utf-8", locale.getpreferredencoding(False), "gbk"): + if not encoding or encoding.lower() in seen: + continue + seen.add(encoding.lower()) + try: + return data.decode(encoding) + except (UnicodeDecodeError, LookupError): + continue + return data.decode("utf-8", errors="replace") + + +class _ShellSubprocess: + """``subprocess`` stand-in for the two harness shell backends.""" + + __slots__ = ("_real",) + + def __init__(self, real: Any) -> None: + self._real = real + + def __getattr__(self, name: str) -> Any: + return getattr(self._real, name) + + def run(self, *args: Any, **kwargs: Any) -> Any: + text_mode = bool(kwargs.get("text") or kwargs.get("universal_newlines")) + if not text_mode or kwargs.get("encoding"): + return self._real.run(*args, **kwargs) + forwarded = dict(kwargs) + forwarded.pop("text", None) + forwarded.pop("universal_newlines", None) + result = self._real.run(*args, **forwarded) + stdout = result.stdout + stderr = result.stderr + if not isinstance(stdout, bytes) and not isinstance(stderr, bytes): + return result + return subprocess.CompletedProcess( + result.args, + result.returncode, + decode_child_output(stdout) if isinstance(stdout, bytes) else stdout, + decode_child_output(stderr) if isinstance(stderr, bytes) else stderr, + ) + + +def install_shell_output_decoding() -> None: + """Decode harness shell output once per process, unless harness already does.""" + global _INSTALLED + if _INSTALLED: + return + local_shell = importlib.import_module("octop_harness.backends.local_shell") + if hasattr(local_shell, "decode_child_output"): + _INSTALLED = True + logger.info("harness already decodes child output; skipping octop shell patch") + return + for module_name in _SHELL_MODULES: + module = importlib.import_module(module_name) + current = module.subprocess + if not isinstance(current, _ShellSubprocess): + module.subprocess = _ShellSubprocess(current) # type: ignore[attr-defined] + _INSTALLED = True + logger.info("installed shell output decoding (utf-8, locale, gbk)") diff --git a/src/octop/infra/bridge/manager.py b/src/octop/infra/bridge/manager.py index c30c854b4..b4f511991 100644 --- a/src/octop/infra/bridge/manager.py +++ b/src/octop/infra/bridge/manager.py @@ -19,6 +19,7 @@ from octop.infra.bridge.ids import ( PROTOCOL_VERSION, format_bridge_agent_id, + restore_peer_path_ids, rewrite_peer_agent_ids, rewrite_peer_stream_frame, ) @@ -34,7 +35,6 @@ PeerTurnRejected, PeerTurnRunner, bridge_turn_ws_payload, - peer_turn_relay_finished, ) from octop.infra.bridge.transport import ( ADVERTISED_BRIDGE_FEATURES, @@ -42,6 +42,7 @@ BRIDGE_TUNNEL_MAX_BODY_BYTES, BRIDGE_WS_MAX_BYTES, FEATURE_BODY_CHUNKS, + FEATURE_TURN_CONTROL, BridgeSession, peer_feature_set, ) @@ -258,6 +259,7 @@ def __init__( self._sessions: dict[str, BridgeSession] = {} self._client_tasks: dict[str, asyncio.Task[None]] = {} self._turn_waiters: dict[str, asyncio.Queue[dict[str, Any]]] = {} + self._listen_stops: dict[str, asyncio.Event] = {} self._browser_waiters: dict[str, asyncio.Queue[dict[str, Any]]] = {} self._browser_peer_clients: dict[str, asyncio.Queue[dict[str, Any] | None]] = {} self._asgi_app: Any | None = None @@ -1543,8 +1545,32 @@ async def _handle_inbound_turn(self, connection_id: str, payload: dict[str, Any] if q is not None: await q.put(payload) return + if msg_type == "turn.unlisten" and request_id: + self._stop_peer_listen(request_id) + return + if msg_type == "turn.listen": + await self._execute_peer_listen(connection_id, payload) + return if msg_type == "turn.start": await self._execute_peer_turn(connection_id, payload) + return + if msg_type == "turn.cancel": + await self._execute_peer_cancel(connection_id, payload) + return + if msg_type == "turn.steer_remove": + await self._execute_peer_steer_remove(connection_id, payload) + return + if request_id: + sess = self._sessions.get(connection_id) + if sess is not None and not sess.closed: + await sess.send_json( + { + "type": "turn.error", + "request_id": request_id, + "message": f"unknown turn type: {msg_type}", + } + ) + await sess.send_json({"type": "turn.end", "request_id": request_id}) async def _execute_peer_turn(self, connection_id: str, payload: dict[str, Any]) -> None: """Peer asked us to run a turn on a local agent; stream chunks back.""" @@ -1623,6 +1649,10 @@ async def send_frame(frame: dict[str, Any]) -> None: payload=payload, ws_connection_id=bridge_conn_id, ) + if prepared.inbound is None: + # Mid-turn steer: frames already went to the live subscriber. + await sess.send_json({"type": "turn.end", "request_id": request_id}) + return hub.subscribe(prepared.thread_id, bridge_conn_id) await sess.send_json( { @@ -1635,15 +1665,178 @@ async def send_frame(frame: dict[str, Any]) -> None: } ) channel_manager.enqueue(WS_CHANNEL_ID, prepared.inbound) - while True: - if sess.closed: - break + # Host ``turn.ended`` is not the end of a team room. Members flush + # only after the host goes idle. Keep this subscriber until the + # hub unlistens, same as a local dashboard socket. + stop = asyncio.Event() + self._listen_stops[request_id] = stop + + async def _queue_stop() -> None: + await stop.wait() + await chunk_queue.put(None) + + stop_task = asyncio.create_task(_queue_stop()) + try: + while True: + if sess.closed: + break + item = await chunk_queue.get() + if item is None: + break + if sess.closed: + break + stamped = stamp_thread_id(item, prepared.thread_id) + await sess.send_json( + { + "type": "turn.chunk", + "request_id": request_id, + "frame": stamped, + } + ) + if not sess.closed: + await sess.send_json({"type": "turn.end", "request_id": request_id}) + finally: + stop_task.cancel() + with suppress(asyncio.CancelledError): + await stop_task + self._listen_stops.pop(request_id, None) + except PeerTurnRejected as exc: + await sess.send_json( + { + "type": "turn.error", + "request_id": request_id, + "message": str(exc)[:500], + } + ) + except Exception as exc: + logger.exception("bridge peer turn failed") + await sess.send_json( + { + "type": "turn.error", + "request_id": request_id, + "message": str(exc)[:500], + } + ) + finally: + hub.unregister(bridge_conn_id) + + def _stop_peer_listen(self, request_id: str) -> None: + stop = self._listen_stops.get(request_id) + if stop is not None and not stop.is_set(): + stop.set() + + async def _execute_peer_listen(self, connection_id: str, payload: dict[str, Any]) -> None: + """Subscribe to a peer room so team members / wrap-up keep flowing.""" + from octop.infra.agents.teams.team_manager import ( # noqa: PLC0415 + subscribe_with_member_catchup, + ) + from octop.infra.gateway.ws.ws_hub import stamp_thread_id + + request_id = str(payload.get("request_id") or "").strip() + agent_id = str(payload.get("agent_id") or "").strip() + thread_id = str(payload.get("thread_id") or "").strip() + sess = self._sessions.get(connection_id) + if sess is None or not request_id or not agent_id or not thread_id: + return + row = self._repo.get(connection_id) + if row is None: + return + if self._asgi_app is None: + await sess.send_json( + { + "type": "turn.error", + "request_id": request_id, + "message": "bridge owner unavailable", + } + ) + await sess.send_json({"type": "turn.end", "request_id": request_id}) + return + server = self._asgi_app.state.octop_server + user = ( + server.app_runtime.user_manager.get_by_id(row.owner_user_id) + if server.app_runtime is not None + else None + ) + if user is None or server.app_runtime is None: + await sess.send_json( + { + "type": "turn.error", + "request_id": request_id, + "message": "bridge owner unavailable", + } + ) + await sess.send_json({"type": "turn.end", "request_id": request_id}) + return + gateway = server.app_runtime.gateway + hub = gateway.ws_hub + thread_id = restore_peer_path_ids( + thread_id, + bridge_agent_id=format_bridge_agent_id(connection_id, agent_id), + remote_agent_id=agent_id, + ) + found = gateway.thread_registry.get_thread(thread_id) + if found is None or found.agent_id != agent_id or found.user_id != user.id: + await sess.send_json( + { + "type": "turn.error", + "request_id": request_id, + "message": f"thread {thread_id!r} not found", + } + ) + await sess.send_json({"type": "turn.end", "request_id": request_id}) + return + + bridge_conn_id = f"bridge-listen-{request_id}" + chunk_queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue() + stop = asyncio.Event() + self._listen_stops[request_id] = stop + + async def send_frame(frame: dict[str, Any]) -> None: + await chunk_queue.put(frame) + + hub.register(bridge_conn_id, send_frame, user_id=user.id) + + async def _queue_stop() -> None: + await stop.wait() + await chunk_queue.put(None) + + stop_task = asyncio.create_task(_queue_stop()) + try: + + async def _send_catchup(frame: dict[str, Any]) -> None: + await sess.send_json( + { + "type": "turn.chunk", + "request_id": request_id, + "frame": stamp_thread_id(frame, thread_id), + } + ) + + def _subscribe(tid: str = thread_id, cid: str = bridge_conn_id) -> None: + hub.subscribe(tid, cid) + + await subscribe_with_member_catchup( + gateway, + thread_id, + subscribe=_subscribe, + send=_send_catchup, + ) + await sess.send_json( + { + "type": "turn.chunk", + "request_id": request_id, + "frame": { + "type": "turn_status", + "thread_id": thread_id, + "active": hub.is_turn_active(thread_id), + }, + } + ) + while not sess.closed: item = await chunk_queue.get() if item is None: break - if sess.closed: - break - stamped = stamp_thread_id(item, prepared.thread_id) + stamped = stamp_thread_id(item, thread_id) await sess.send_json( { "type": "turn.chunk", @@ -1651,28 +1844,233 @@ async def send_frame(frame: dict[str, Any]) -> None: "frame": stamped, } ) - if peer_turn_relay_finished(str(stamped.get("type") or "")): - break + if not sess.closed: + await sess.send_json({"type": "turn.end", "request_id": request_id}) + except Exception: + logger.exception("bridge peer listen failed") + if not sess.closed: + await sess.send_json( + { + "type": "turn.error", + "request_id": request_id, + "message": "bridge listen failed", + } + ) + await sess.send_json({"type": "turn.end", "request_id": request_id}) + finally: + stop_task.cancel() + with suppress(asyncio.CancelledError): + await stop_task + self._listen_stops.pop(request_id, None) + hub.unregister(bridge_conn_id) + + async def _peer_control_owner( + self, + connection_id: str, + payload: dict[str, Any], + ) -> tuple[str, str, Any, Any, Any] | None: + """Resolve ``(request_id, agent_id, sess, user, gateway)`` or fail the request.""" + request_id = str(payload.get("request_id") or "").strip() + agent_id = str(payload.get("agent_id") or "").strip() + sess = self._sessions.get(connection_id) + if sess is None or not request_id or not agent_id: + return None + row = self._repo.get(connection_id) + if row is None: + return None + + async def _fail(message: str) -> None: + if sess.closed: + return + await sess.send_json( + { + "type": "turn.error", + "request_id": request_id, + "message": message, + } + ) await sess.send_json({"type": "turn.end", "request_id": request_id}) - except PeerTurnRejected as exc: + + if self._asgi_app is None: + await _fail("bridge owner unavailable") + return None + server = self._asgi_app.state.octop_server + user = ( + server.app_runtime.user_manager.get_by_id(row.owner_user_id) + if server.app_runtime is not None + else None + ) + if user is None or server.app_runtime is None: + await _fail("bridge owner unavailable") + return None + gateway = server.app_runtime.gateway + return request_id, agent_id, sess, user, gateway + + def _restore_peer_thread_id( + self, + connection_id: str, + agent_id: str, + thread_id: str, + ) -> str: + return restore_peer_path_ids( + thread_id, + bridge_agent_id=format_bridge_agent_id(connection_id, agent_id), + remote_agent_id=agent_id, + ) + + async def _execute_peer_cancel(self, connection_id: str, payload: dict[str, Any]) -> None: + """Stop the peer stream for a dashboard cancel, matching local WS.""" + resolved = await self._peer_control_owner(connection_id, payload) + if resolved is None: + return + request_id, agent_id, sess, user, gateway = resolved + thread_id = self._restore_peer_thread_id( + connection_id, + agent_id, + str(payload.get("thread_id") or "").strip(), + ) + if not thread_id: await sess.send_json( { "type": "turn.error", "request_id": request_id, - "message": str(exc)[:500], + "message": "cancel requires thread_id", } ) - except Exception as exc: - logger.exception("bridge peer turn failed") + await sess.send_json({"type": "turn.end", "request_id": request_id}) + return + found = gateway.thread_registry.get_thread(thread_id) + if found is None or found.agent_id != agent_id or found.user_id != user.id: await sess.send_json( { "type": "turn.error", "request_id": request_id, - "message": str(exc)[:500], + "message": f"thread {thread_id!r} not found", } ) - finally: - hub.unregister(bridge_conn_id) + await sess.send_json({"type": "turn.end", "request_id": request_id}) + return + gateway.steer.mark_cancelling(thread_id) + discarded = gateway.steer.discard(thread_id, reason="turn_cancelled") + if discarded is not None: + await gateway.ws_hub.push_to_thread( + thread_id, + { + "type": "steer.discarded", + "seq": discarded.seq, + "client_pending_ids": discarded.client_pending_ids, + "pending_ids": discarded.pending_ids, + "turn_id": discarded.turn_id, + "reason": discarded.reason, + "thread_id": thread_id, + }, + ) + server = self._asgi_app.state.octop_server if self._asgi_app is not None else None + if server is not None and server.app_runtime is not None: + server.app_runtime.agent_registry.cancel_stream(agent_id, thread_id) + if not sess.closed: + await sess.send_json({"type": "turn.end", "request_id": request_id}) + + async def _execute_peer_steer_remove(self, connection_id: str, payload: dict[str, Any]) -> None: + """Withdraw a queued steer on the peer, matching local ``steer_remove``.""" + resolved = await self._peer_control_owner(connection_id, payload) + if resolved is None: + return + request_id, agent_id, sess, user, gateway = resolved + thread_id = self._restore_peer_thread_id( + connection_id, + agent_id, + str(payload.get("thread_id") or "").strip(), + ) + turn_id = str(payload.get("turn_id") or "").strip() + client_pending_id = str(payload.get("client_pending_id") or "").strip() + pending_id = str(payload.get("pending_id") or "").strip() + + async def send_ack(frame: dict[str, Any]) -> None: + if sess.closed: + return + await sess.send_json( + { + "type": "turn.chunk", + "request_id": request_id, + "frame": frame, + } + ) + + if not thread_id or not turn_id: + await send_ack( + { + "type": "steer.ack", + "status": "rejected", + "reason": "turn_id_required", + "client_pending_id": client_pending_id or None, + "thread_id": thread_id or None, + } + ) + if not sess.closed: + await sess.send_json({"type": "turn.end", "request_id": request_id}) + return + found = gateway.thread_registry.get_thread(thread_id) + if found is None or found.agent_id != agent_id or found.user_id != user.id: + await send_ack( + { + "type": "steer.ack", + "status": "rejected", + "reason": "forbidden", + "client_pending_id": client_pending_id or None, + "thread_id": thread_id, + } + ) + if not sess.closed: + await sess.send_json({"type": "turn.end", "request_id": request_id}) + return + if not client_pending_id or not pending_id: + await send_ack( + { + "type": "steer.ack", + "status": "rejected", + "reason": "client_pending_id_required", + "client_pending_id": client_pending_id or None, + "thread_id": thread_id, + } + ) + if not sess.closed: + await sess.send_json({"type": "turn.end", "request_id": request_id}) + return + result = gateway.steer.remove( + thread_id, + turn_id=turn_id, + client_pending_id=client_pending_id, + pending_id=pending_id, + ) + await send_ack( + { + "type": "steer.ack", + "status": "removed", + "turn_id": result.turn_id, + "pending_id": result.pending_id, + "client_pending_id": result.client_pending_id, + "already_drained": result.already_drained or None, + "already_gone": result.already_gone or None, + "thread_id": thread_id, + } + ) + if result.emit_removed and result.seq is not None: + await gateway.ws_hub.push_to_thread( + thread_id, + { + "type": "steer.removed", + "seq": result.seq, + "client_pending_id": result.client_pending_id, + "pending_id": result.pending_id, + "turn_id": result.turn_id, + "already_drained": result.already_drained or None, + "already_gone": result.already_gone or None, + "thread_id": thread_id, + }, + ) + if not sess.closed: + await sess.send_json({"type": "turn.end", "request_id": request_id}) async def relay_user_turn( self, @@ -1701,7 +2099,14 @@ async def relay_user_turn( ) saw_turn_ended = False while True: - msg = await sess.next_queue_item(queue, timeout=600.0) + try: + msg = await sess.next_queue_item(queue, timeout=600.0) + except TimeoutError: + # After host ``turn.ended`` the peer may hold the room + # subscriber with no frames for a long time (team idle). + if saw_turn_ended: + continue + raise msg_type = str(msg.get("type") or "") if msg_type == "turn.chunk": frame = msg.get("frame") @@ -1730,12 +2135,191 @@ async def relay_user_turn( if not saw_turn_ended: await on_frame({"type": "turn.ended"}) return + except asyncio.CancelledError: + if not sess.closed: + with suppress(Exception): + await sess.send_json({"type": "turn.unlisten", "request_id": request_id}) + raise except (TimeoutError, ConnectionError) as exc: await self._unregister_session(connection_id) raise self._bridge_link_error(exc) from exc finally: self.close_turn_waiter(request_id) + async def relay_user_listen( + self, + *, + connection_id: str, + owner_user_id: int, + remote_agent_id: str, + thread_id: str, + on_frame: Any, + ) -> None: + """Keep a listen-only subscription on the peer room thread.""" + import uuid + + tid = (thread_id or "").strip() + if not tid: + return + self.get_owned(connection_id, owner_user_id) + sess = await self._require_live_session(connection_id) + request_id = uuid.uuid4().hex + queue = await self.open_turn_waiter(request_id) + try: + await sess.send_json( + { + "type": "turn.listen", + "request_id": request_id, + "agent_id": remote_agent_id, + "thread_id": tid, + } + ) + while True: + try: + msg = await sess.next_queue_item(queue, timeout=600.0) + except TimeoutError: + continue + msg_type = str(msg.get("type") or "") + if msg_type == "turn.chunk": + frame = msg.get("frame") + if isinstance(frame, dict): + await on_frame( + rewrite_peer_stream_frame( + connection_id, + frame, + remote_agent_id=remote_agent_id, + ) + ) + elif msg_type == "turn.error": + await on_frame( + { + "type": "error", + "message": str(msg.get("message") or "bridge listen error"), + } + ) + return + elif msg_type == "turn.end": + return + except asyncio.CancelledError: + if not sess.closed: + with suppress(Exception): + await sess.send_json({"type": "turn.unlisten", "request_id": request_id}) + raise + except (TimeoutError, ConnectionError) as exc: + await self._unregister_session(connection_id) + raise self._bridge_link_error(exc) from exc + finally: + self.close_turn_waiter(request_id) + + async def _relay_turn_control( + self, + *, + connection_id: str, + owner_user_id: int, + remote_agent_id: str, + msg_type: str, + payload: dict[str, Any], + on_frame: Any, + ) -> None: + """One-shot peer control (cancel / steer_remove) with chunk forwarding.""" + import uuid + + self.get_owned(connection_id, owner_user_id) + sess = await self._require_live_session(connection_id) + if FEATURE_TURN_CONTROL not in sess.peer_features: + await on_frame( + { + "type": "error", + "message": f"peer does not support {msg_type}", + } + ) + return + request_id = uuid.uuid4().hex + queue = await self.open_turn_waiter(request_id) + try: + await sess.send_json( + { + "type": msg_type, + "request_id": request_id, + "agent_id": remote_agent_id, + **payload, + } + ) + while True: + msg = await sess.next_queue_item(queue, timeout=30.0) + msg_kind = str(msg.get("type") or "") + if msg_kind == "turn.chunk": + frame = msg.get("frame") + if isinstance(frame, dict): + await on_frame( + rewrite_peer_stream_frame( + connection_id, + frame, + remote_agent_id=remote_agent_id, + ) + ) + elif msg_kind == "turn.error": + await on_frame( + { + "type": "error", + "message": str(msg.get("message") or "bridge control error"), + } + ) + return + elif msg_kind == "turn.end": + return + except (TimeoutError, ConnectionError) as exc: + await self._unregister_session(connection_id) + raise self._bridge_link_error(exc) from exc + finally: + self.close_turn_waiter(request_id) + + async def relay_turn_cancel( + self, + *, + connection_id: str, + owner_user_id: int, + remote_agent_id: str, + thread_id: str, + on_frame: Any, + ) -> None: + """Ask the peer to cancel the live stream for ``thread_id``.""" + tid = (thread_id or "").strip() + if not tid: + await on_frame({"type": "error", "message": "cancel requires thread_id"}) + return + await self._relay_turn_control( + connection_id=connection_id, + owner_user_id=owner_user_id, + remote_agent_id=remote_agent_id, + msg_type="turn.cancel", + payload={"thread_id": tid}, + on_frame=on_frame, + ) + + async def relay_turn_steer_remove( + self, + *, + connection_id: str, + owner_user_id: int, + remote_agent_id: str, + payload: dict[str, Any], + on_frame: Any, + ) -> None: + """Ask the peer to withdraw a queued mid-turn steer.""" + await self._relay_turn_control( + connection_id=connection_id, + owner_user_id=owner_user_id, + remote_agent_id=remote_agent_id, + msg_type="turn.steer_remove", + payload={ + key: payload[key] + for key in ("thread_id", "turn_id", "client_pending_id", "pending_id") + if isinstance(payload.get(key), str) and str(payload[key]).strip() + }, + on_frame=on_frame, + ) + # -- browser stream relay ------------------------------------------------ async def open_browser_waiter(self, request_id: str) -> asyncio.Queue[dict[str, Any]]: diff --git a/src/octop/infra/bridge/peer_turn.py b/src/octop/infra/bridge/peer_turn.py index 94c7d9fbc..86041dca1 100644 --- a/src/octop/infra/bridge/peer_turn.py +++ b/src/octop/infra/bridge/peer_turn.py @@ -46,7 +46,7 @@ def peer_turn_relay_finished(frame_type: str) -> bool: class PreparedPeerTurnLike(Protocol): thread_id: str - inbound: Any + inbound: Any # ``None`` when the turn was admitted as a mid-turn steer. class PeerTurnRunner(Protocol): diff --git a/src/octop/infra/bridge/transport.py b/src/octop/infra/bridge/transport.py index 99f0de45e..a3446aa97 100644 --- a/src/octop/infra/bridge/transport.py +++ b/src/octop/infra/bridge/transport.py @@ -27,7 +27,12 @@ BRIDGE_TUNNEL_MAX_BODY_BYTES = 8 * 1024 * 1024 # Advertised when both sides can split an upload across several frames. FEATURE_BODY_CHUNKS = "body_chunks" -ADVERTISED_BRIDGE_FEATURES = [FEATURE_BODY_CHUNKS] +# Mid-turn cancel / steer_remove on the peer (hub fails fast if the peer is older). +FEATURE_TURN_CONTROL = "turn_control" +ADVERTISED_BRIDGE_FEATURES = [ + FEATURE_BODY_CHUNKS, + FEATURE_TURN_CONTROL, +] # Each chunk stays well under the default WebSocket frame cap after base64. BODY_CHUNK_RAW_BYTES = 256 * 1024 # Upper bound once ``body_chunks`` is negotiated. The peer upload route still @@ -245,8 +250,9 @@ async def handle_message(self, raw: str) -> None: fut.set_result(complete) return if msg_type.startswith("turn.") and self._on_turn_frame is not None: - # turn.start can run for minutes — never block the recv / heartbeat loop. - if msg_type == "turn.start": + # turn.start / turn.listen can run for minutes — never block the + # recv / heartbeat loop. + if msg_type in {"turn.start", "turn.listen", "turn.cancel", "turn.steer_remove"}: handler = self._on_turn_frame async def _run_turn() -> None: diff --git a/src/octop/infra/bridge/tunnel_policy.py b/src/octop/infra/bridge/tunnel_policy.py index 73601a6d7..bc900998a 100644 --- a/src/octop/infra/bridge/tunnel_policy.py +++ b/src/octop/infra/bridge/tunnel_policy.py @@ -32,7 +32,8 @@ r"providers/resolved|" r"providers/active-model|" r"knowledge-bases|" - r"knowledge-bases/capability" + r"knowledge-bases/capability|" + r"settings/hitl" r")$" ) diff --git a/src/octop/infra/errors.py b/src/octop/infra/errors.py index 80ebf8fac..c0be64eaf 100644 --- a/src/octop/infra/errors.py +++ b/src/octop/infra/errors.py @@ -204,7 +204,9 @@ class ErrorCode(StrEnum): ErrorCode.CONNECTOR_ALREADY_BOUND: 409, ErrorCode.CONNECTOR_MCP_LOAD_FAILED: 502, ErrorCode.BRIDGE_NOT_FOUND: 404, - ErrorCode.BRIDGE_AUTH_FAILED: 401, + # Peer credential failure. 400 matches other invalid-credential codes; + # 401 would make the dashboard treat it as a dead local session. + ErrorCode.BRIDGE_AUTH_FAILED: 400, ErrorCode.BRIDGE_PEER_UNREACHABLE: 502, ErrorCode.BRIDGE_NOT_CONNECTED: 503, ErrorCode.BRIDGE_TUNNEL_FAILED: 502, diff --git a/src/octop/infra/server.py b/src/octop/infra/server.py index c4ca98e70..1fa467f9c 100644 --- a/src/octop/infra/server.py +++ b/src/octop/infra/server.py @@ -409,8 +409,10 @@ async def start(self) -> None: self._setup_logging() ensure_nofile_limit() from octop.infra.backend.grep_safety import install_safe_grep_fallback # noqa: PLC0415 + from octop.infra.backend.shell_output import install_shell_output_decoding # noqa: PLC0415 install_safe_grep_fallback() + install_shell_output_decoding() config = ensure_mobile_capabilities_probed(self.paths.config) self.config = config diff --git a/tests/integration/test_bridge_api.py b/tests/integration/test_bridge_api.py index 93bed22f1..e048665ae 100644 --- a/tests/integration/test_bridge_api.py +++ b/tests/integration/test_bridge_api.py @@ -5,6 +5,8 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch +from octop.infra.errors import ErrorCode, OctopError + async def test_bridge_list_empty(env: Any) -> None: client, _srv, auth = env @@ -72,6 +74,28 @@ async def test_bridge_probe_returns_expert_list(env: Any) -> None: assert listed.json() == [] +async def test_bridge_probe_peer_rejection_stays_logged_in(env: Any) -> None: + """Peer credential failure is 400, so the dashboard does not clear the local session.""" + client, _srv, auth = env + with patch( + "octop.infra.bridge.manager.login_peer", + new=AsyncMock( + side_effect=OctopError(ErrorCode.BRIDGE_AUTH_FAILED, "peer rejected credentials") + ), + ): + r = await client.post( + "/api/bridge/probe", + headers=auth, + json={ + "peer_base_url": "https://demo.octop.chat", + "peer_username": "peer", + "password": "secret", + }, + ) + assert r.status_code == 400, r.text + assert r.json()["error"]["code"] == "BRIDGE_AUTH_FAILED" + + async def test_bridge_probe_validation(env: Any) -> None: client, _srv, auth = env r = await client.post( diff --git a/tests/unit/backend/test_grep_safety.py b/tests/unit/backend/test_grep_safety.py index dda07757d..09339706c 100644 --- a/tests/unit/backend/test_grep_safety.py +++ b/tests/unit/backend/test_grep_safety.py @@ -1,28 +1,42 @@ -"""Safe Python grep fallback (issue #2101).""" +"""Grep runtime patches: bounded Python fallback (#2101) and ripgrep UTF-8 (#1959).""" from __future__ import annotations import os +import subprocess +import sys from pathlib import Path +from typing import Any import pytest from deepagents.backends.filesystem import FilesystemBackend from octop.infra.backend.grep_safety import ( _MAX_LINE_BYTES, + _command_is_ripgrep, + _RipgrepSubprocess, install_safe_grep_fallback, is_virtual_fs_path, ) posix_only = pytest.mark.skipif(os.name != "posix", reason="virtual-fs skip is POSIX-only") +posix_script = pytest.mark.skipif( + os.name != "posix", reason="executable named rg is a POSIX script" +) def test_install_is_idempotent() -> None: + import deepagents.backends.filesystem as filesystem + install_safe_grep_fallback() - first = FilesystemBackend._python_search + search = FilesystemBackend._python_search + pipe = filesystem.subprocess install_safe_grep_fallback() - assert FilesystemBackend._python_search is first - assert getattr(FilesystemBackend._python_search, "__octop_safe_grep__", False) + assert FilesystemBackend._python_search is search + assert filesystem.subprocess is pipe + assert getattr(search, "__octop_safe_grep__", False) + assert isinstance(pipe, _RipgrepSubprocess) + assert subprocess.Popen is not pipe.Popen def test_workspace_named_proc_is_still_searched(tmp_path: Path) -> None: @@ -98,3 +112,88 @@ def test_dockerfiles_install_ripgrep() -> None: for rel in ("docker/Dockerfile", "fnos/docker/Dockerfile"): text = (root / rel).read_text(encoding="utf-8") assert "ripgrep" in text, f"{rel} must install ripgrep" + + +def test_command_is_ripgrep() -> None: + assert _command_is_ripgrep(["/usr/bin/rg", "--json"]) + assert _command_is_ripgrep(["C:\\Tools\\rg.exe", "--json"]) + assert _command_is_ripgrep((b"/opt/bin/rg",)) + assert not _command_is_ripgrep(["/usr/bin/python", "-c", "pass"]) + assert not _command_is_ripgrep("rg --json") + assert not _command_is_ripgrep(b"rg") + assert not _command_is_ripgrep([]) + assert not _command_is_ripgrep([object()]) + + +def test_popen_forces_utf8_only_for_ripgrep() -> None: + real = _RecordingSubprocess() + proxy = _RipgrepSubprocess(real) + proxy.Popen(["/usr/bin/rg", "--json"], stdout=subprocess.PIPE, text=True) + proxy.Popen( + ["C:\\ripgrep\\rg.EXE"], + text=True, + encoding="latin-1", + errors="strict", + ) + proxy.Popen([sys.executable, "-c", "pass"], text=True) + proxy.Popen(["/usr/bin/rg"], stdout=subprocess.PIPE) + + rg_kwargs = real.calls[0][1] + assert rg_kwargs["encoding"] == "utf-8" + assert rg_kwargs["errors"] == "replace" + explicit = real.calls[1][1] + assert explicit["encoding"] == "latin-1" + assert explicit["errors"] == "strict" + assert "encoding" not in real.calls[2][1] + assert "encoding" not in real.calls[3][1] + assert proxy.PIPE == "pipe" + + +@posix_script +def test_installed_proxy_reads_utf8_that_is_illegal_in_gbk(tmp_path: Path) -> None: + """Bytes that crash a cp936 decode must come back as text, not an exception.""" + install_safe_grep_fallback() + import deepagents.backends.filesystem as filesystem + + rg = tmp_path / "rg" + rg.write_text("#!/bin/sh\nprintf '%s' '中文'\n", encoding="utf-8") + rg.chmod(0o755) + proc = filesystem.subprocess.Popen( + [os.fspath(rg)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + stdout, _stderr = proc.communicate(timeout=10) + assert stdout == "中文" + + +@posix_script +def test_installed_proxy_replaces_invalid_utf8(tmp_path: Path) -> None: + install_safe_grep_fallback() + import deepagents.backends.filesystem as filesystem + + rg = tmp_path / "rg" + rg.write_text("#!/bin/sh\nprintf '\\377\\376'\n", encoding="utf-8") + rg.chmod(0o755) + proc = filesystem.subprocess.Popen( + [os.fspath(rg)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + stdout, _stderr = proc.communicate(timeout=10) + assert stdout == "\ufffd\ufffd" + + +class _RecordingSubprocess: + def __init__(self) -> None: + self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] + + def Popen(self, *args: Any, **kwargs: Any) -> str: + self.calls.append((args, kwargs)) + return "proc" + + @property + def PIPE(self) -> str: + return "pipe" diff --git a/tests/unit/backend/test_shell_output.py b/tests/unit/backend/test_shell_output.py new file mode 100644 index 000000000..446a9aace --- /dev/null +++ b/tests/unit/backend/test_shell_output.py @@ -0,0 +1,152 @@ +"""Host shell output survives a non-UTF-8 locale (issue #1959).""" + +from __future__ import annotations + +import locale +import subprocess +import sys +from pathlib import Path + +from octop_harness.backends.local_shell import HarnessLocalShellBackend + +from octop.infra.backend.shell_output import ( + _ShellSubprocess, + decode_child_output, + install_shell_output_decoding, +) + +# Valid GBK, illegal in UTF-8 and in cp1252 (GitHub Windows). Forces the GBK step. +_GBK_ONLY = bytes((0x81, 0x40)) + + +def _after_utf8(data: bytes) -> str: + """What the chain should return once strict UTF-8 has failed. + + A single-byte locale such as cp1252 accepts every byte in ``中文测试``'s + GBK form, so that console code page wins and GBK is not reached. Locales + that reject the bytes (UTF-8, and cp1252 for ``_GBK_ONLY``) fall through. + """ + encoding = locale.getpreferredencoding(False) + if encoding: + try: + return data.decode(encoding) + except (UnicodeDecodeError, LookupError): + pass + try: + return data.decode("gbk") + except UnicodeDecodeError: + return data.decode("utf-8", errors="replace") + + +def _script(tmp_path: Path, name: str, payload: bytes) -> str: + path = tmp_path / name + path.write_text( + "import sys\nsys.stdout.buffer.write(" + repr(payload) + ")\n", + encoding="utf-8", + ) + return f'"{sys.executable}" "{path}"' + + +def test_decode_child_output_chain() -> None: + assert decode_child_output(None) == "" + assert decode_child_output(b"") == "" + assert decode_child_output("已经是文本") == "已经是文本" + assert decode_child_output("中文测试".encode()) == "中文测试" + assert decode_child_output("中文测试".encode("gbk")) == _after_utf8("中文测试".encode("gbk")) + assert decode_child_output(_GBK_ONLY) == "丂" + mixed = b"before" + bytes([0xFF]) + b"after" + assert decode_child_output(mixed) == _after_utf8(mixed) + assert "before" in decode_child_output(mixed) + assert "after" in decode_child_output(mixed) + + +def test_cp1252_locale_accepts_gbk_text_but_not_every_gbk_byte(monkeypatch) -> None: + """GitHub Windows is cp1252: that code page swallows ``中文测试``'s GBK bytes.""" + monkeypatch.setattr(locale, "getpreferredencoding", lambda _do_setlocale=False: "cp1252") + text = "中文测试".encode("gbk") + assert decode_child_output(text) == text.decode("cp1252") + assert decode_child_output(_GBK_ONLY) == "丂" + mixed = b"before" + bytes([0xFF]) + b"after" + assert decode_child_output(mixed) == "beforeÿafter" + + +def test_install_is_idempotent_and_leaves_stdlib() -> None: + import octop_harness.backends.bwrap_shell as bwrap_shell + import octop_harness.backends.local_shell as local_shell + + install_shell_output_decoding() + first = local_shell.subprocess + install_shell_output_decoding() + assert local_shell.subprocess is first + assert isinstance(first, _ShellSubprocess) + assert isinstance(bwrap_shell.subprocess, _ShellSubprocess) + assert subprocess.Popen is not first.run + assert local_shell.subprocess.TimeoutExpired is subprocess.TimeoutExpired + assert local_shell.subprocess.DEVNULL is subprocess.DEVNULL + assert local_shell.subprocess.list2cmdline(["a b"]) == subprocess.list2cmdline(["a b"]) + + +def test_explicit_encoding_is_left_alone() -> None: + import octop_harness.backends.local_shell as local_shell + + install_shell_output_decoding() + result = local_shell.subprocess.run( + [sys.executable, "-c", "import sys; sys.stdout.buffer.write(bytes([255]))"], + check=False, + capture_output=True, + text=True, + encoding="latin-1", + ) + assert result.stdout == "ÿ" + + +def test_execute_decodes_utf8_and_gbk(tmp_path: Path) -> None: + install_shell_output_decoding() + backend = HarnessLocalShellBackend(root_dir=tmp_path, virtual_mode=False) + + gbk_bytes = "中文测试".encode("gbk") + utf8 = backend.execute(_script(tmp_path, "utf8.py", "中文测试".encode())) + gbk = backend.execute(_script(tmp_path, "gbk.py", gbk_bytes)) + mixed = backend.execute(_script(tmp_path, "mixed.py", b"before" + bytes([0xFF]) + b"after")) + gbk_only = backend.execute(_script(tmp_path, "gbk_only.py", _GBK_ONLY)) + + assert utf8.exit_code == 0 + assert "中文测试" in utf8.output + assert "" not in utf8.output + assert gbk.exit_code == 0 + assert _after_utf8(gbk_bytes) in gbk.output + assert "" not in gbk.output + assert gbk_only.exit_code == 0 + assert "丂" in gbk_only.output + assert mixed.exit_code == 0 + assert "before" in mixed.output + assert "after" in mixed.output + assert "" not in mixed.output + + +def test_bwrap_module_run_uses_the_same_decoder() -> None: + import octop_harness.backends.bwrap_shell as bwrap_shell + + install_shell_output_decoding() + result = bwrap_shell.subprocess.run( + [sys.executable, "-c", "import sys; sys.stdout.buffer.write(bytes([0x81, 0x40]))"], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert result.stdout == "丂" + + +def test_skips_install_when_harness_already_decodes(monkeypatch) -> None: + import octop_harness.backends.local_shell as local_shell + + import octop.infra.backend.shell_output as shell_output + + monkeypatch.setattr(shell_output, "_INSTALLED", False) + monkeypatch.setattr(local_shell, "decode_child_output", lambda data: data, raising=False) + current = local_shell.subprocess + if isinstance(current, _ShellSubprocess): + monkeypatch.setattr(local_shell, "subprocess", current._real) + shell_output.install_shell_output_decoding() + assert not isinstance(local_shell.subprocess, _ShellSubprocess) diff --git a/tests/unit/bridge/test_bridge_proxy_local.py b/tests/unit/bridge/test_bridge_proxy_local.py index 0066ab6c5..f22d67097 100644 --- a/tests/unit/bridge/test_bridge_proxy_local.py +++ b/tests/unit/bridge/test_bridge_proxy_local.py @@ -108,6 +108,9 @@ def test_resolve_header_tunneled_paths() -> None: assert kb is not None env = resolve_tunnel_target("/api/browser/env-status", "bridge:cid1:aid1") assert env is not None + hitl = resolve_tunnel_target("/api/settings/hitl", "bridge:cid1:aid1") + assert hitl is not None + assert hitl.remote_path == "/api/settings/hitl" def test_resolve_ignores_local_header_and_unscoped_paths() -> None: diff --git a/tests/unit/bridge/test_peer_turn.py b/tests/unit/bridge/test_peer_turn.py index f30a720d1..ceca8cd7d 100644 --- a/tests/unit/bridge/test_peer_turn.py +++ b/tests/unit/bridge/test_peer_turn.py @@ -74,3 +74,323 @@ async def test_empty_peer_turn_is_rejected() -> None: payload={}, ws_connection_id="ws", ) + + +@pytest.mark.asyncio +async def test_prepare_peer_turn_admits_steer_when_busy(monkeypatch: pytest.MonkeyPatch) -> None: + from unittest.mock import MagicMock + + from octop.api.routers.chat.turn import PreparedDashboardTurn + from octop.infra.gateway.steer import SteerCoordinator + + steer = SteerCoordinator() + steer.begin_turn("thr-1") + pushed: list[dict[str, object]] = [] + + class _Hub: + def is_turn_active(self, thread_id: str) -> bool: + return thread_id == "thr-1" + + async def push_to_thread(self, thread_id: str, frame: dict[str, object]) -> None: + assert thread_id == "thr-1" + pushed.append(frame) + + gateway = MagicMock() + gateway.steer = steer + gateway.ws_hub = _Hub() + gateway.processor = None + runtime = MagicMock() + runtime.gateway = gateway + server = MagicMock() + server.app_runtime = runtime + + async def _fake_prepare(*_args: object, **_kwargs: object) -> PreparedDashboardTurn: + return PreparedDashboardTurn( + thread_id="thr-1", + session_key="sk", + mcp_servers=None, + skills=None, + model_ref=None, + inbound_content=[], + composer_context=None, + inbound_attachments=[], + ) + + monkeypatch.setattr("octop.api.bridge_peer.prepare_dashboard_turn", _fake_prepare) + build = MagicMock() + monkeypatch.setattr("octop.api.bridge_peer.build_dashboard_inbound", build) + + class _User: + id = 1 + is_admin = False + + result = await prepare_peer_dashboard_turn( + server=server, + user=_User(), + agent_id="a1", + payload={ + "text": "use plan B", + "thread_id": "thr-1", + "delivery": "auto", + "client_pending_id": "cp1", + }, + ws_connection_id="ws", + ) + assert result.thread_id == "thr-1" + assert result.inbound is None + build.assert_not_called() + types = [str(frame.get("type") or "") for frame in pushed] + assert "steer.ack" in types + assert "steer.queued" in types + + +@pytest.mark.asyncio +async def test_prepare_peer_turn_enqueues_when_hub_turn_is_idle( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from unittest.mock import MagicMock + + from octop.api.routers.chat.turn import PreparedDashboardTurn + from octop.infra.gateway.steer import SteerCoordinator + + steer = SteerCoordinator() + steer.begin_turn("thr-1") + + class _Hub: + def is_turn_active(self, _thread_id: str) -> bool: + return False + + async def push_to_thread(self, *_args: object, **_kwargs: object) -> None: + raise AssertionError("idle room must enqueue, not steer") + + gateway = MagicMock() + gateway.steer = steer + gateway.ws_hub = _Hub() + gateway.processor = None + runtime = MagicMock() + runtime.gateway = gateway + server = MagicMock() + server.app_runtime = runtime + + async def _fake_prepare(*_args: object, **_kwargs: object) -> PreparedDashboardTurn: + return PreparedDashboardTurn( + thread_id="thr-1", + session_key="sk", + mcp_servers=None, + skills=None, + model_ref=None, + inbound_content=[], + composer_context=None, + inbound_attachments=[], + ) + + monkeypatch.setattr("octop.api.bridge_peer.prepare_dashboard_turn", _fake_prepare) + inbound = object() + monkeypatch.setattr( + "octop.api.bridge_peer.build_dashboard_inbound", + MagicMock(return_value=inbound), + ) + + class _User: + id = 1 + is_admin = False + + result = await prepare_peer_dashboard_turn( + server=server, + user=_User(), + agent_id="a1", + payload={"text": "next question", "thread_id": "thr-1"}, + ws_connection_id="ws", + ) + assert result.inbound is inbound + + +@pytest.mark.asyncio +async def test_execute_peer_turn_skips_enqueue_when_steered() -> None: + from unittest.mock import AsyncMock, MagicMock + + from octop.api.bridge_peer import PreparedPeerTurn + from octop.infra.bridge.manager import BridgeManager + from octop.infra.bridge.transport import BridgeSession + + sent: list[dict[str, object]] = [] + + async def capture(payload: dict[str, object]) -> None: + sent.append(payload) + + sess = BridgeSession(connection_id="cid1", send_text=AsyncMock()) + sess.send_json = capture # type: ignore[method-assign] + + user = MagicMock() + user.id = 1 + runtime = MagicMock() + runtime.user_manager.get_by_id.return_value = user + runtime.gateway.channel_manager.enqueue = MagicMock() + runtime.gateway.ws_hub.register = MagicMock() + runtime.gateway.ws_hub.subscribe = MagicMock() + runtime.gateway.ws_hub.unregister = MagicMock() + server = MagicMock() + server.app_runtime = runtime + app = MagicMock() + app.state.octop_server = server + + async def runner(**_kwargs: object) -> PreparedPeerTurn: + return PreparedPeerTurn(thread_id="thr-1", inbound=None) + + mgr = BridgeManager( + bridge_repo=MagicMock(), + secret_repo=MagicMock(), + user_repo=MagicMock(), + advertise_base_url="http://local.test", + ) + mgr.bind_asgi_app(app, peer_turn_runner=runner) + mgr._sessions["cid1"] = sess + mgr._repo.get = MagicMock(return_value=MagicMock(owner_user_id=1)) # type: ignore[method-assign] + + await mgr._execute_peer_turn( + "cid1", + {"request_id": "r1", "agent_id": "agent", "text": "steer me"}, + ) + runtime.gateway.channel_manager.enqueue.assert_not_called() + runtime.gateway.ws_hub.subscribe.assert_not_called() + assert sent == [{"type": "turn.end", "request_id": "r1"}] + + +@pytest.mark.asyncio +async def test_execute_peer_cancel_stops_host_stream() -> None: + from unittest.mock import AsyncMock, MagicMock + + from octop.infra.bridge.manager import BridgeManager + from octop.infra.bridge.transport import BridgeSession + + thread = MagicMock() + thread.agent_id = "agent" + thread.user_id = 1 + discarded = MagicMock( + seq=2, + client_pending_ids=["c1"], + pending_ids=["p1"], + turn_id="t1", + reason="turn_cancelled", + ) + gateway = MagicMock() + gateway.thread_registry.get_thread.return_value = thread + gateway.steer.discard.return_value = discarded + gateway.ws_hub.push_to_thread = AsyncMock() + + user = MagicMock() + user.id = 1 + runtime = MagicMock() + runtime.user_manager.get_by_id.return_value = user + runtime.gateway = gateway + server = MagicMock() + server.app_runtime = runtime + app = MagicMock() + app.state.octop_server = server + + sent: list[dict[str, object]] = [] + + async def capture(payload: dict[str, object]) -> None: + sent.append(payload) + + sess = BridgeSession(connection_id="cid1", send_text=AsyncMock()) + sess.send_json = capture # type: ignore[method-assign] + + mgr = BridgeManager( + bridge_repo=MagicMock(), + secret_repo=MagicMock(), + user_repo=MagicMock(), + advertise_base_url="http://local.test", + ) + mgr._asgi_app = app + mgr._sessions["cid1"] = sess + mgr._repo.get = MagicMock(return_value=MagicMock(owner_user_id=1)) # type: ignore[method-assign] + + await mgr._execute_peer_cancel( + "cid1", + {"request_id": "r1", "agent_id": "agent", "thread_id": "thr-1"}, + ) + gateway.steer.mark_cancelling.assert_called_once_with("thr-1") + runtime.agent_registry.cancel_stream.assert_called_once_with("agent", "thr-1") + gateway.ws_hub.push_to_thread.assert_awaited() + assert sent[-1] == {"type": "turn.end", "request_id": "r1"} + + +@pytest.mark.asyncio +async def test_execute_peer_turn_holds_room_until_unlisten() -> None: + import asyncio + from unittest.mock import AsyncMock, MagicMock + + from octop.api.bridge_peer import PreparedPeerTurn + from octop.infra.bridge.manager import BridgeManager + from octop.infra.bridge.transport import BridgeSession + from octop.infra.gateway.ws.ws_hub import WebSocketHub + + sent: list[dict[str, object]] = [] + + async def capture(payload: dict[str, object]) -> None: + sent.append(payload) + + sess = BridgeSession(connection_id="cid1", send_text=AsyncMock()) + sess.send_json = capture # type: ignore[method-assign] + + hub = WebSocketHub() + user = MagicMock() + user.id = 1 + runtime = MagicMock() + runtime.user_manager.get_by_id.return_value = user + runtime.gateway.channel_manager.enqueue = MagicMock() + runtime.gateway.ws_hub = hub + server = MagicMock() + server.app_runtime = runtime + app = MagicMock() + app.state.octop_server = server + + async def runner(**_kwargs: object) -> PreparedPeerTurn: + return PreparedPeerTurn(thread_id="thr-1", inbound={"text": "hi"}) + + mgr = BridgeManager( + bridge_repo=MagicMock(), + secret_repo=MagicMock(), + user_repo=MagicMock(), + advertise_base_url="http://local.test", + ) + mgr.bind_asgi_app(app, peer_turn_runner=runner) + mgr._sessions["cid1"] = sess + mgr._repo.get = MagicMock(return_value=MagicMock(owner_user_id=1)) # type: ignore[method-assign] + + turn_task = asyncio.create_task( + mgr._execute_peer_turn( + "cid1", + {"request_id": "r1", "agent_id": "agent", "text": "hi"}, + ) + ) + for _ in range(50): + subscribed = False + for item in sent: + frame = item.get("frame") + if isinstance(frame, dict) and frame.get("type") == "thread": + subscribed = True + break + if subscribed: + break + await asyncio.sleep(0.01) + await hub.push_to_thread("thr-1", {"type": "turn.ended", "thread_id": "thr-1"}) + await asyncio.sleep(0.05) + await hub.push_to_thread( + "thr-1", + {"type": "token", "agent_id": "doctor", "content": "磁盘还剩 8G"}, + ) + await asyncio.sleep(0.02) + assert not turn_task.done() + assert not any(item.get("type") == "turn.end" for item in sent) + mgr._stop_peer_listen("r1") + await asyncio.wait_for(turn_task, timeout=1.0) + frames = [ + item["frame"] + for item in sent + if item.get("type") == "turn.chunk" and isinstance(item.get("frame"), dict) + ] + assert any(frame.get("type") == "turn.ended" for frame in frames) + assert any(frame.get("content") == "磁盘还剩 8G" for frame in frames) + assert sent[-1] == {"type": "turn.end", "request_id": "r1"} diff --git a/tests/unit/bridge/test_session_liveness.py b/tests/unit/bridge/test_session_liveness.py index c06be0cb6..df074b87c 100644 --- a/tests/unit/bridge/test_session_liveness.py +++ b/tests/unit/bridge/test_session_liveness.py @@ -245,6 +245,178 @@ async def on_frame(frame: dict[str, Any]) -> None: assert [frame["type"] for frame in frames] == ["turn.started", "done", "turn.ended"] +@pytest.mark.asyncio +async def test_relay_user_turn_unlistens_when_cancelled() -> None: + from octop.infra.bridge.manager import BridgeManager + from octop.infra.db.repos.bridge_connections import BridgeConnectionRow + + row = BridgeConnectionRow( + pk=1, + connection_id="cid1", + owner_user_id=1, + peer_base_url="https://peer.example", + peer_username="alice", + display_name="云端", + notes=None, + icon_name=None, + credential_blob=b"enc", + access_token_blob=b"tok", + token_expires_at=None, + status="connected", + last_error=None, + last_seen_at=None, + auto_reconnect=True, + created_at=1, + updated_at=1, + ) + mgr = BridgeManager( + bridge_repo=MagicMock(), + secret_repo=MagicMock(), + user_repo=MagicMock(), + advertise_base_url="http://local.test", + ) + mgr.get_owned = MagicMock(return_value=row) # type: ignore[method-assign] + sess = BridgeSession(connection_id="cid1", send_text=AsyncMock()) + mgr._sessions["cid1"] = sess + started = asyncio.Event() + sent: list[str] = [] + + async def send_json(payload: dict[str, Any]) -> None: + sent.append(str(payload.get("type") or "")) + if payload.get("type") == "turn.start": + started.set() + request_id = str(payload["request_id"]) + queue = mgr._turn_waiters[request_id] + await queue.put( + { + "type": "turn.chunk", + "request_id": request_id, + "frame": {"type": "turn.ended", "thread_id": "thr-1"}, + } + ) + return + if payload.get("type") == "turn.unlisten": + request_id = str(payload["request_id"]) + queue = mgr._turn_waiters.get(request_id) + if queue is not None: + await queue.put({"type": "turn.end", "request_id": request_id}) + + sess.send_json = send_json # type: ignore[method-assign] + frames: list[dict[str, Any]] = [] + + async def on_frame(frame: dict[str, Any]) -> None: + frames.append(frame) + + task = asyncio.create_task( + mgr.relay_user_turn( + connection_id="cid1", + owner_user_id=1, + remote_agent_id="agent", + turn_payload={"text": "hi"}, + on_frame=on_frame, + ) + ) + await asyncio.wait_for(started.wait(), timeout=1.0) + await asyncio.sleep(0.02) + assert [frame["type"] for frame in frames] == ["turn.ended"] + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert "turn.unlisten" in sent + + +@pytest.mark.asyncio +async def test_relay_user_listen_forwards_member_frames_until_unlisten() -> None: + from octop.infra.bridge.manager import BridgeManager + from octop.infra.db.repos.bridge_connections import BridgeConnectionRow + + row = BridgeConnectionRow( + pk=1, + connection_id="cid1", + owner_user_id=1, + peer_base_url="https://peer.example", + peer_username="alice", + display_name="云端", + notes=None, + icon_name=None, + credential_blob=b"enc", + access_token_blob=b"tok", + token_expires_at=None, + status="connected", + last_error=None, + last_seen_at=None, + auto_reconnect=True, + created_at=1, + updated_at=1, + ) + mgr = BridgeManager( + bridge_repo=MagicMock(), + secret_repo=MagicMock(), + user_repo=MagicMock(), + advertise_base_url="http://local.test", + ) + mgr.get_owned = MagicMock(return_value=row) # type: ignore[method-assign] + sess = BridgeSession(connection_id="cid1", send_text=AsyncMock()) + mgr._sessions["cid1"] = sess + started = asyncio.Event() + + async def send_json(payload: dict[str, Any]) -> None: + if payload.get("type") == "turn.listen": + started.set() + request_id = str(payload["request_id"]) + queue = mgr._turn_waiters[request_id] + await queue.put( + { + "type": "turn.chunk", + "request_id": request_id, + "frame": { + "type": "turn_status", + "thread_id": "thr-room", + "active": False, + }, + } + ) + await queue.put( + { + "type": "turn.chunk", + "request_id": request_id, + "frame": { + "type": "token", + "agent_id": "doctor", + "content": "磁盘还剩 8G", + }, + } + ) + return + if payload.get("type") == "turn.unlisten": + request_id = str(payload["request_id"]) + queue = mgr._turn_waiters[request_id] + await queue.put({"type": "turn.end", "request_id": request_id}) + + sess.send_json = send_json # type: ignore[method-assign] + frames: list[dict[str, Any]] = [] + + async def on_frame(frame: dict[str, Any]) -> None: + frames.append(frame) + + task = asyncio.create_task( + mgr.relay_user_listen( + connection_id="cid1", + owner_user_id=1, + remote_agent_id="host", + thread_id="thr-room", + on_frame=on_frame, + ) + ) + await asyncio.wait_for(started.wait(), timeout=1.0) + await asyncio.sleep(0.05) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert [frame.get("type") for frame in frames][:2] == ["turn_status", "token"] + assert frames[1].get("content") == "磁盘还剩 8G" + + @pytest.mark.asyncio async def test_relay_user_turn_fails_fast_when_session_closes() -> None: from octop.infra.bridge.manager import BridgeManager @@ -294,3 +466,161 @@ async def killer() -> None: on_frame=frames.append, ) assert exc_info.value.code == ErrorCode.BRIDGE_NOT_CONNECTED + + +def _bridge_row() -> Any: + from octop.infra.db.repos.bridge_connections import BridgeConnectionRow + + return BridgeConnectionRow( + pk=1, + connection_id="cid1", + owner_user_id=1, + peer_base_url="https://peer.example", + peer_username="alice", + display_name="云端", + notes=None, + icon_name=None, + credential_blob=b"enc", + access_token_blob=b"tok", + token_expires_at=None, + status="connected", + last_error=None, + last_seen_at=None, + auto_reconnect=True, + created_at=1, + updated_at=1, + ) + + +@pytest.mark.asyncio +async def test_relay_turn_cancel_sends_control_and_forwards_end() -> None: + from octop.infra.bridge.manager import BridgeManager + from octop.infra.bridge.transport import FEATURE_TURN_CONTROL + + mgr = BridgeManager( + bridge_repo=MagicMock(), + secret_repo=MagicMock(), + user_repo=MagicMock(), + advertise_base_url="http://local.test", + ) + mgr.get_owned = MagicMock(return_value=_bridge_row()) # type: ignore[method-assign] + sess = BridgeSession( + connection_id="cid1", + send_text=AsyncMock(), + peer_features={FEATURE_TURN_CONTROL}, + ) + mgr._sessions["cid1"] = sess + sent: list[dict[str, Any]] = [] + + async def send_json(payload: dict[str, Any]) -> None: + sent.append(payload) + if payload.get("type") != "turn.cancel": + return + request_id = str(payload["request_id"]) + queue = mgr._turn_waiters[request_id] + await queue.put({"type": "turn.end", "request_id": request_id}) + + sess.send_json = send_json # type: ignore[method-assign] + frames: list[dict[str, Any]] = [] + await mgr.relay_turn_cancel( + connection_id="cid1", + owner_user_id=1, + remote_agent_id="agent", + thread_id="thr-1", + on_frame=frames.append, + ) + assert sent[0]["type"] == "turn.cancel" + assert sent[0]["thread_id"] == "thr-1" + assert sent[0]["agent_id"] == "agent" + assert frames == [] + + +@pytest.mark.asyncio +async def test_relay_turn_steer_remove_forwards_ack() -> None: + from octop.infra.bridge.manager import BridgeManager + from octop.infra.bridge.transport import FEATURE_TURN_CONTROL + + mgr = BridgeManager( + bridge_repo=MagicMock(), + secret_repo=MagicMock(), + user_repo=MagicMock(), + advertise_base_url="http://local.test", + ) + mgr.get_owned = MagicMock(return_value=_bridge_row()) # type: ignore[method-assign] + sess = BridgeSession( + connection_id="cid1", + send_text=AsyncMock(), + peer_features={FEATURE_TURN_CONTROL}, + ) + mgr._sessions["cid1"] = sess + + async def send_json(payload: dict[str, Any]) -> None: + if payload.get("type") != "turn.steer_remove": + return + request_id = str(payload["request_id"]) + queue = mgr._turn_waiters[request_id] + await queue.put( + { + "type": "turn.chunk", + "request_id": request_id, + "frame": { + "type": "steer.ack", + "status": "removed", + "client_pending_id": "cp1", + }, + } + ) + await queue.put({"type": "turn.end", "request_id": request_id}) + + sess.send_json = send_json # type: ignore[method-assign] + frames: list[dict[str, Any]] = [] + + async def on_frame(frame: dict[str, Any]) -> None: + frames.append(frame) + + await mgr.relay_turn_steer_remove( + connection_id="cid1", + owner_user_id=1, + remote_agent_id="agent", + payload={ + "thread_id": "thr-1", + "turn_id": "t1", + "client_pending_id": "cp1", + "pending_id": "p1", + }, + on_frame=on_frame, + ) + assert frames == [ + {"type": "steer.ack", "status": "removed", "client_pending_id": "cp1"}, + ] + + +@pytest.mark.asyncio +async def test_relay_turn_cancel_fails_fast_without_peer_feature() -> None: + from octop.infra.bridge.manager import BridgeManager + + mgr = BridgeManager( + bridge_repo=MagicMock(), + secret_repo=MagicMock(), + user_repo=MagicMock(), + advertise_base_url="http://local.test", + ) + mgr.get_owned = MagicMock(return_value=_bridge_row()) # type: ignore[method-assign] + sess = BridgeSession(connection_id="cid1", send_text=AsyncMock()) + mgr._sessions["cid1"] = sess + sent: list[dict[str, Any]] = [] + sess.send_json = AsyncMock(side_effect=lambda payload: sent.append(payload)) # type: ignore[method-assign] + frames: list[dict[str, Any]] = [] + + async def on_frame(frame: dict[str, Any]) -> None: + frames.append(frame) + + await mgr.relay_turn_cancel( + connection_id="cid1", + owner_user_id=1, + remote_agent_id="agent", + thread_id="thr-1", + on_frame=on_frame, + ) + assert sent == [] + assert frames == [{"type": "error", "message": "peer does not support turn.cancel"}] diff --git a/tests/unit/bridge/test_tunnel_policy.py b/tests/unit/bridge/test_tunnel_policy.py index 0359c1145..96a8974f5 100644 --- a/tests/unit/bridge/test_tunnel_policy.py +++ b/tests/unit/bridge/test_tunnel_policy.py @@ -32,6 +32,8 @@ def test_allows_composer_readonly_paths() -> None: assert is_tunnel_path_allowed("GET", "/api/providers/active-model") assert is_tunnel_path_allowed("GET", "/api/knowledge-bases") assert is_tunnel_path_allowed("GET", "/api/knowledge-bases/capability") + assert is_tunnel_path_allowed("GET", "/api/settings/hitl") + assert not is_tunnel_path_allowed("PUT", "/api/settings/hitl") assert is_tunnel_path_allowed("GET", "/api/agents/01ABC/status") assert is_tunnel_path_allowed("GET", "/api/agents/01ABC/subagents") assert is_tunnel_path_allowed("GET", "/api/agents/01ABC/tool-settings") diff --git a/tests/unit/gateway/test_global_processor_team.py b/tests/unit/gateway/test_global_processor_team.py index 67d100b91..d046fdb99 100644 --- a/tests/unit/gateway/test_global_processor_team.py +++ b/tests/unit/gateway/test_global_processor_team.py @@ -1070,6 +1070,69 @@ async def fake_stream(_agent_id: str, _request: dict[str, object]) -> object: harness.aget_history.assert_awaited() +@pytest.mark.asyncio +async def test_live_member_catchup_replays_partial_then_clears( + processor_env: dict, +) -> None: + from octop_harness.request import ChatRequest + + processor = processor_env["processor"] + gateway = processor_env["gateway"] + started = asyncio.Event() + release = asyncio.Event() + + async def fake_stream(_agent_id: str, _request: dict[str, object]) -> object: + yield {"type": "token", "content": "hello "} + started.set() + await release.wait() + yield {"type": "token", "content": "doc"} + + processor._agent_manager.stream = fake_stream + harness = MagicMock() + harness.aget_history = AsyncMock(return_value=[]) + processor._agent_manager.get_agent.return_value = harness + + task = asyncio.create_task( + processor.teams.stream_peer_to_room( + ChatRequest(messages="task", thread_id="thr_parent~child", agent_id="child"), + room_thread_id="thr_parent", + speaker_id="child", + ) + ) + await asyncio.wait_for(started.wait(), timeout=2) + + caught: list[dict[str, object]] = [] + + async def send(frame: dict[str, object]) -> None: + caught.append(frame) + + await processor.teams.deliver_live_member_catchup( + "thr_parent", + subscribe=lambda: None, + send=send, + ) + assert caught == [ + { + "type": "token", + "content": "hello ", + "agent_id": "child", + "agent": "child", + "team_snapshot": True, + "team_live": True, + "thread_id": "thr_parent", + } + ] + release.set() + await asyncio.wait_for(task, timeout=2) + caught.clear() + await processor.teams.deliver_live_member_catchup( + "thr_parent", + subscribe=lambda: gateway.ws_hub.subscribe("thr_parent", "late"), + send=send, + ) + assert caught == [] + + @pytest.mark.asyncio async def test_stream_team_peer_relays_tokens_to_member_and_room( processor_env: dict, diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py index 418b938b3..70afc0117 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -20,6 +20,12 @@ def test_octop_error_default_status_for_known_code(): assert err.status == 403 +def test_bridge_auth_failed_is_not_http_401(): + """Peer login failure must not share the local-session 401 status.""" + err = OctopError(ErrorCode.BRIDGE_AUTH_FAILED, "peer rejected credentials") + assert err.status == 400 + + def test_octop_error_to_envelope(): err = OctopError(ErrorCode.AUTH_FAILED, "bad creds") assert err.to_envelope() == { diff --git a/uv.lock b/uv.lock index 13ea79951..8a4d57b15 100644 --- a/uv.lock +++ b/uv.lock @@ -2520,7 +2520,7 @@ wheels = [ [[package]] name = "octop" -version = "1.0.2b9" +version = "1.0.2" source = { editable = "." } dependencies = [ { name = "acme" },