From 058fa37dfc73a676ac71e81ebb52683810831aba Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 03:12:36 +0000 Subject: [PATCH 1/8] feat: unify team engineering on the session forest Hire, chat, Discuss, and the conversation map now treat mounted child sessions as the only durable members. Drop parent-session team-agent shadows after migration, address Team* tools by session id, and align Web/TUI surfaces on the same forest. Co-authored-by: Suddenness --- .changeset/unify-session-team-engineering.md | 5 + README.md | 4 +- README.zh-CN.md | 4 +- apps/nori-code/src/tui/commands/map.ts | 109 +--- apps/nori-code/src/tui/commands/team.ts | 45 +- apps/nori-code/src/tui/utils/team-tree.ts | 30 + .../test/tui/utils/team-tree.test.ts | 20 + apps/nori-web/src/App.tsx | 118 +++- apps/nori-web/src/api/client.ts | 8 + apps/nori-web/src/components/CodeView.tsx | 2 +- .../src/components/SessionAgentTree.tsx | 590 ------------------ .../src/components/SessionMapPage.tsx | 123 ++-- apps/nori-web/src/components/TeamTreePage.tsx | 385 ------------ .../src/components/WorkspaceInspector.tsx | 8 +- .../src/components/session-map/layout.ts | 21 +- apps/nori-web/src/hooks/useChatMessages.ts | 4 +- apps/nori-web/src/utils/session-graph.ts | 18 +- apps/nori-web/src/utils/session-mount.ts | 11 +- apps/nori-web/test/SessionAgentTree.test.ts | 578 ----------------- apps/nori-web/test/SessionMapPage.test.ts | 38 +- apps/nori-web/test/TeamTreePage.test.ts | 150 ----- apps/nori-web/test/WorkspaceInspector.test.ts | 12 +- docs/adr/pre.1-session-node-graph.md | 6 +- docs/en/guides/migration.md | 2 +- docs/en/guides/team-engineering.md | 14 +- docs/en/reference/tools.md | 8 +- docs/zh/guides/migration.md | 2 +- docs/zh/guides/team-engineering.md | 14 +- docs/zh/reference/tools.md | 8 +- .../agent-core/src/agent/context/index.ts | 6 + .../policies/default-tool-approve.ts | 4 + packages/agent-core/src/agent/tool/index.ts | 4 + .../src/profile/default/team-engineering.md | 2 +- packages/agent-core/src/rpc/core-api.ts | 25 +- packages/agent-core/src/rpc/core-impl.ts | 356 +++++++++-- .../src/services/session/session.ts | 3 + .../src/services/session/sessionService.ts | 178 ++++-- .../src/session/department-runtime.ts | 105 ++++ packages/agent-core/src/session/index.ts | 536 ++++++++++++++-- .../agent-core/src/session/mount-metadata.ts | 5 + .../agent-core/src/session/subagent-host.ts | 251 ++++++-- packages/agent-core/src/session/team-agent.md | 33 +- .../builtin/collaboration/session-topology.ts | 105 ++++ .../builtin/collaboration/team-status.md | 8 +- .../src/tools/builtin/collaboration/team.ts | 12 +- .../agent-core/src/tools/builtin/index.ts | 1 + .../test/services/session-service.test.ts | 248 ++++---- .../test/session/subagent-host.test.ts | 28 +- packages/protocol/src/rest/session.ts | 24 +- packages/server/src/routes/sessions.ts | 35 ++ 50 files changed, 2026 insertions(+), 2280 deletions(-) create mode 100644 .changeset/unify-session-team-engineering.md delete mode 100644 apps/nori-web/src/components/SessionAgentTree.tsx delete mode 100644 apps/nori-web/src/components/TeamTreePage.tsx delete mode 100644 apps/nori-web/test/SessionAgentTree.test.ts delete mode 100644 apps/nori-web/test/TeamTreePage.test.ts create mode 100644 packages/agent-core/src/session/department-runtime.ts create mode 100644 packages/agent-core/src/tools/builtin/collaboration/session-topology.ts diff --git a/.changeset/unify-session-team-engineering.md b/.changeset/unify-session-team-engineering.md new file mode 100644 index 00000000..0f94d5ce --- /dev/null +++ b/.changeset/unify-session-team-engineering.md @@ -0,0 +1,5 @@ +--- +"nori-code": minor +--- + +Make department members the same Sessions as conversation-map cards, so hire, chat, Discuss, and `/map` share one forest. Hire with TeamCreate or the map; open a card to work in that session. diff --git a/README.md b/README.md index 829d2edf..e5ce6613 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Nori is a coding-agent workspace forked from [Kimi Code CLI](https://github.com/ - Any agent may hire members with `TeamCreate` and chair its own department, bounded by `team.maxDepth` (default `2`, maximum `5`). - A Discuss round is one department: a parent plus its direct members. A node never chairs and participates at the same time. -- Hiring uses the same path as the conversation map: create a **real child session**, mount it with `parent_session_id`, and show it as a session card. The runtime also **dual-writes** a team agent so Discuss/Assign still address this department by agent id. That dual-write is an implementation seam, not a finished unified identity (see [Honest gaps](#3-honest-gaps)). +- Hiring uses the same path as the conversation map: create a **real child session**, mount it with `parent_session_id`, and show it as a session card. Work, tools, sibling chat, and identity live on that child session. Discuss/Assign address the session id (or display name). - `TeamDismiss` removes a member and **deletes** that child session. Unmount on the map is a user action: detach without deleting. A session has one parent; part-time / second-parent hire is not supported. ### Discuss, then Code @@ -98,7 +98,7 @@ The project owner described LSP and Git as a rough shell (「毛坯房」). Afte ### Other gaps verified in this repo - **TUI test debt** (from the changelog): about 66 failing tests across 25 files in `apps/nori-code`. They still assert the pre-rename `kimi-code` home directory, user-agent, and command names, or slash commands the registry has not exposed for a long time. The count moved from 68 to 66 only because SubAgent’s own tests were deleted with the feature. -- **Dual-write hire:** The product path is “empty child session + mount + a team agent on the parent.” Discuss/Assign speak agent ids; the map speaks session ids. After a crash, an idempotent sync has to reattach both sides. Known seam, not a unified identity model. +- **Existing dual-write sessions migrate on load:** Older hires that still have a parent-session team agent are bound to (or materialized as) a child session, then the shadow is dropped. New hires never create that shadow. - **Kimi naming leftovers:** The TUI coordinator is still `KimiTUI`; build macros are `__KIMI_CODE_*`; native cache paths can still land under `kimi-code`; the docs theme and many VitePress pages still carry upstream branding and SubAgent copy. `pnpm check:brand` catches public brand drift; it does not mean every internal identifier is gone. - **Map peer/service edges live in localStorage:** Parent edges are server `parent_session_id`. Peer edges, service edges, annotations, and pinned positions live in `nori-session-map-doc`. Clearing site data drops them. Server-side graph storage has not landed (see `docs/adr/pre.1-session-node-graph.md`). - **`nori.yaml` is not a DAG scheduler:** The file still contains `phases:`, step lists, and leftover SubAgent rules. What the runtime actually uses is rule-prompt injection plus review / memory / bug-hunt **gates** (score activity, inject instructions). There is no `depends_on` node runner. Older README text that sold this YAML as policy-as-code DAG orchestration overclaimed. diff --git a/README.zh-CN.md b/README.zh-CN.md index 6af34cc6..9c45e7f8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -23,7 +23,7 @@ Nori 是一个从 [Kimi Code CLI](https://github.com/MoonshotAI/kimi-code)(MIT - 每个 Agent 可以用 `TeamCreate` 雇佣自己的成员,并主持自己的部门。深度受 `team.maxDepth` 约束(默认 `2`,上限 `5`)。 - 一次 Discuss 的范围是**一个部门**:父节点 + 它的直接成员。节点不会同时当主席又当发言人。 -- 雇佣走的是和会话地图同一条路径:创建一个**真实子会话**,用 `parent_session_id` 挂到你下面,地图上就是一张会话卡片。同时会 **dual-write** 一个团队 Agent,好让 Discuss / Assign 仍按本部门的 agent id 寻址。这是实现上的双写,不是已经磨平的统一身份(见 [缺口](#3-诚实的缺口))。 +- 雇佣走的是和会话地图同一条路径:创建一个**真实子会话**,用 `parent_session_id` 挂到你下面,地图上就是一张会话卡片。工作、工具、兄弟交流和身份都住在这个子会话上。Discuss / Assign 按 session id(或展示名)寻址。 - `TeamDismiss` 移除成员并**删除**对应子会话。地图上的 Unmount 是用户操作:只拆挂载,不删会话。一个会话目前只能有一个父节点。 ### 先 Discuss,再 Code @@ -98,7 +98,7 @@ Codex 和 Claude Code 仍然提供**打磨过的一次性 subagent fan-out**( ### 代码里核对过的其它缺口 - **TUI 测试债**(changelog 原文):`apps/nori-code` 里约 66 个测试、25 个文件失败。一部分还在断言改名之前的 `kimi-code` 家目录、UA、命令名;一部分在断言注册表很久没再暴露的斜杠命令。数量从 68 降到 66,只是因为 SubAgent 自己的测试随功能一起删了。 -- **雇佣 dual-write**:产品路径是「空子会话 + 挂载 + 父会话里再挂一个 team agent」。Discuss / Assign 仍按 agent id 说话;地图按 session id 说话。crash 之后要靠幂等 sync 把两边对齐。这是已知接缝,不是已经统一的身份模型。 +- **已有 dual-write 会话会在加载时迁移:** 父会话里还挂着 team agent 的旧雇佣,会绑到(或物化成)子会话,再拆掉影子。新雇佣不再创建影子。 - **Kimi 命名残留**:TUI 协调器仍叫 `KimiTUI`;构建宏是 `__KIMI_CODE_*`;原生缓存目录仍能落到 `kimi-code`;文档站组件和不少 VitePress 页面还带着上游品牌与 SubAgent 说法。`pnpm check:brand` 管的是对外品牌漂移,不是一次清完所有内部标识。 - **Map 的 peer / service 边只在 localStorage**:父边以服务端 `parent_session_id` 为准。对等边、服务边、标注、钉住的位置写在 `nori-session-map-doc` 里,换浏览器或清站点数据就会丢。服务端图存储还没落地(见 `docs/adr/pre.1-session-node-graph.md`)。 - **`nori.yaml` 不是 DAG 调度器**:文件里有 `phases:`、步骤、甚至旧的 SubAgent 规则,但运行时真正读的是规则 prompt 注入,以及 review / memory / bug-hunt **闸门**(复杂度打分后往上下文里塞指令)。没有一个按 `depends_on` 跑节点的编排引擎。旧 README 把这份 YAML 写成「策略即代码的 DAG」,那是超售。 diff --git a/apps/nori-code/src/tui/commands/map.ts b/apps/nori-code/src/tui/commands/map.ts index 91cf8c67..09f158e0 100644 --- a/apps/nori-code/src/tui/commands/map.ts +++ b/apps/nori-code/src/tui/commands/map.ts @@ -3,8 +3,8 @@ import type { SessionGraphSummary, SessionSummary } from '@nori-code/sdk'; import { SessionMapBrowserComponent } from '../components/dialogs/session-map-browser'; import { TextInputDialogComponent } from '../components/dialogs/text-input-dialog'; import { formatErrorMessage } from '../utils/event-payload'; -import { parentSessionIdOf } from '../utils/session-map-tree'; -import { teamAgentsFromSessionMetadata, type TeamAgentSnapshot } from '../utils/team-tree'; +import { parentSessionIdOf, mountRoleOf, mountMandateOf, sessionMapLabel } from '../utils/session-map-tree'; +import { teamAgentsFromMountedChildren, type TeamAgentSnapshot } from '../utils/team-tree'; import type { SlashCommandHost } from './dispatch'; type MappedTeamAgent = TeamAgentSnapshot & { readonly hostSessionId: string }; @@ -26,49 +26,19 @@ export async function handleMapCommand(host: SlashCommandHost): Promise { edges: graph.edges, currentSessionId: host.session?.id, onOpen: (session) => { - const parentId = parentSessionIdOf(session.metadata as Record | undefined); - if (parentId !== undefined) { - void (async () => { - let member = mappedTeamAgents.find( - (agent) => - agent.kind === 'team' - && agent.mountedSessionId === session.id - && agent.hostSessionId === parentId, - ); - if (member === undefined) { - mappedTeamAgents = await refreshTeamAgents(host, graph.nodes); - member = mappedTeamAgents.find( - (agent) => - agent.kind === 'team' - && agent.mountedSessionId === session.id - && agent.hostSessionId === parentId, - ); - } - if (member === undefined) { - host.showError(`Mounted session "${session.id}" has no owning Team agent.`); - render(); - return; - } - await openMountedTeamAgent(host, member, render); - })().catch((error) => { - host.showError(formatErrorMessage(error)); - render(); - }); - return; - } void openSession(host, session.id, render); }, onMount: (child, parent) => { void applyMount(host, child, parent, async () => { graph = await host.harness.getSessionGraph({ workDir: host.state.appState.workDir }); - mappedTeamAgents = await refreshTeamAgents(host, graph.nodes); + mappedTeamAgents = await refreshTeamAgents(host, graph); render(); }, render); }, onUnmount: (session) => { void applyUnmount(host, session, async () => { graph = await host.harness.getSessionGraph({ workDir: host.state.appState.workDir }); - mappedTeamAgents = await refreshTeamAgents(host, graph.nodes); + mappedTeamAgents = await refreshTeamAgents(host, graph); render(); }, render); }, @@ -79,27 +49,38 @@ export async function handleMapCommand(host: SlashCommandHost): Promise { ); }; - mappedTeamAgents = await refreshTeamAgents(host, graph.nodes); + mappedTeamAgents = await refreshTeamAgents(host, graph); render(); } async function refreshTeamAgents( host: SlashCommandHost, - nodes: readonly SessionSummary[], + graph: SessionGraphSummary, ): Promise { const mapped: MappedTeamAgent[] = []; - for (const node of nodes) { - try { - const metadata = await host.harness.getSessionMetadata(node.id); - mapped.push( - ...teamAgentsFromSessionMetadata(metadata).map((agent) => ({ - ...agent, - hostSessionId: node.id, - })), - ); - } catch { - // The graph update is still useful when one session's metadata is unavailable. - } + const byId = new Map(graph.nodes.map((node) => [node.id, node])); + const childrenByParent = new Map(); + for (const edge of graph.edges) { + const child = byId.get(edge.childSessionId); + if (child === undefined) continue; + const list = childrenByParent.get(edge.parentSessionId) ?? []; + list.push(child); + childrenByParent.set(edge.parentSessionId, list); + } + for (const node of graph.nodes) { + const children = childrenByParent.get(node.id) ?? []; + mapped.push( + ...teamAgentsFromMountedChildren(sessionMapLabel(node), children.map((child) => ({ + id: child.id, + title: child.title, + name: typeof child.metadata?.['mount_name'] === 'string' ? child.metadata['mount_name'] : child.title, + role: mountRoleOf(child), + mandate: mountMandateOf(child), + }))).map((agent) => ({ + ...agent, + hostSessionId: node.id, + })), + ); } const currentSessionId = host.session?.id; host.setAppState({ @@ -108,38 +89,6 @@ async function refreshTeamAgents( return mapped; } -async function openMountedTeamAgent( - host: SlashCommandHost, - member: MappedTeamAgent, - reopen: () => void, -): Promise { - try { - host.restoreEditor(); - let session = host.session; - if (session?.id !== member.hostSessionId) { - session = await host.harness.resumeSession({ id: member.hostSessionId }); - await host.switchToSession(session, `Opened team session (${session.id}).`); - } - if (session === undefined) { - throw new Error(`Team member host session "${member.hostSessionId}" is unavailable.`); - } - const metadata = await session.getSessionMetadata(); - const currentMember = teamAgentsFromSessionMetadata(metadata).find( - (agent) => - agent.kind === 'team' - && agent.agentId === member.agentId - && agent.mountedSessionId === member.mountedSessionId, - ); - if (currentMember === undefined) { - throw new Error(`Team member "${member.name}" is no longer available.`); - } - await host.teamViewController.open(currentMember); - } catch (error) { - host.showError(formatErrorMessage(error)); - reopen(); - } -} - async function openSession( host: SlashCommandHost, sessionId: string, diff --git a/apps/nori-code/src/tui/commands/team.ts b/apps/nori-code/src/tui/commands/team.ts index 7960fb00..6668af42 100644 --- a/apps/nori-code/src/tui/commands/team.ts +++ b/apps/nori-code/src/tui/commands/team.ts @@ -1,6 +1,8 @@ import { TeamBrowserComponent } from '../components/dialogs/team-browser'; import { TeamMemberDetailComponent } from '../components/dialogs/team-member-detail'; -import { currentViewingAgentId, type TeamAgentSnapshot } from '../utils/team-tree'; +import { currentViewingAgentId, teamAgentsFromMountedChildren, type TeamAgentSnapshot } from '../utils/team-tree'; +import { mountMandateOf, mountRoleOf, sessionMapLabel } from '../utils/session-map-tree'; +import { formatErrorMessage } from '../utils/event-payload'; import type { TranscriptEntry } from '../types'; import { showTeamSettingsPicker } from './config'; import type { SlashCommandHost } from './dispatch'; @@ -11,9 +13,37 @@ export async function handleTeamCommand(host: SlashCommandHost, args: string): P await showTeamSettingsPicker(host); return; } + await refreshDepartmentFromGraph(host); showTeamBrowser(host); } +async function refreshDepartmentFromGraph(host: SlashCommandHost): Promise { + const sessionId = host.session?.id; + if (sessionId === undefined) return; + try { + const graph = await host.harness.getSessionGraph({ workDir: host.state.appState.workDir }); + const hostNode = graph.nodes.find((node) => node.id === sessionId); + const children = graph.edges + .filter((edge) => edge.parentSessionId === sessionId) + .map((edge) => graph.nodes.find((node) => node.id === edge.childSessionId)) + .filter((node): node is NonNullable => node !== undefined); + host.setAppState({ + teamAgents: teamAgentsFromMountedChildren( + hostNode === undefined ? (host.state.appState.sessionTitle ?? 'Main') : sessionMapLabel(hostNode), + children.map((child) => ({ + id: child.id, + title: child.title, + name: typeof child.metadata?.['mount_name'] === 'string' ? child.metadata['mount_name'] : child.title, + role: mountRoleOf(child), + mandate: mountMandateOf(child), + })), + ), + }); + } catch (error) { + host.showError(formatErrorMessage(error)); + } +} + function showTeamBrowser(host: SlashCommandHost): void { host.mountEditorReplacement( new TeamBrowserComponent({ @@ -22,6 +52,19 @@ function showTeamBrowser(host: SlashCommandHost): void { discussMode: host.state.appState.discussMode, currentAgentId: currentViewingAgentId(host.state.appState.viewingAgentId), onSelect: (agent) => { + if (agent.mountedSessionId !== undefined && agent.mountedSessionId.length > 0) { + void (async () => { + try { + const session = await host.harness.resumeSession({ id: agent.mountedSessionId! }); + await host.switchToSession(session, `Opened session (${session.id}).`); + host.restoreEditor(); + } catch (error) { + host.showError(String(error)); + showTeamBrowser(host); + } + })(); + return; + } void host.teamViewController.open(agent); }, onDetails: (agent) => { diff --git a/apps/nori-code/src/tui/utils/team-tree.ts b/apps/nori-code/src/tui/utils/team-tree.ts index 3b09af7c..6b54bdd7 100644 --- a/apps/nori-code/src/tui/utils/team-tree.ts +++ b/apps/nori-code/src/tui/utils/team-tree.ts @@ -132,6 +132,36 @@ export function teamAgentsFromSessionMetadata(metadata: unknown): TeamAgentSnaps return snapshots; } +/** Department members as mounted child Sessions of `hostSessionId`. */ +export function teamAgentsFromMountedChildren( + hostTitle: string, + children: ReadonlyArray<{ + readonly id: string; + readonly title?: string; + readonly name?: string; + readonly role?: string; + readonly mandate?: string; + }>, +): TeamAgentSnapshot[] { + return [ + { + agentId: MAIN_AGENT_ID, + kind: 'main', + name: hostTitle.trim().length > 0 ? hostTitle : 'Main', + parentAgentId: null, + }, + ...children.map((child) => ({ + agentId: child.id, + kind: 'team' as const, + name: child.name?.trim() || child.title?.trim() || child.id, + parentAgentId: MAIN_AGENT_ID, + role: child.role, + mandate: child.mandate, + mountedSessionId: child.id, + })), + ]; +} + export function flattenTeamTree(agents: readonly TeamAgentSnapshot[]): TeamTreeRow[] { const byParent = new Map(); for (const agent of agents) { diff --git a/apps/nori-code/test/tui/utils/team-tree.test.ts b/apps/nori-code/test/tui/utils/team-tree.test.ts index e9e80f55..255029cc 100644 --- a/apps/nori-code/test/tui/utils/team-tree.test.ts +++ b/apps/nori-code/test/tui/utils/team-tree.test.ts @@ -10,6 +10,7 @@ import { formatTeamReportsStatus, formatTeamRowSecondary, shouldPaintDiscussUtterance, + teamAgentsFromMountedChildren, teamAgentsFromSessionMetadata, teamChatMessagesFromMetadata, teamHasBlockingReports, @@ -27,6 +28,25 @@ const main: TeamAgentSnapshot = { parentAgentId: null, }; +describe('teamAgentsFromMountedChildren', () => { + it('builds department members from mounted child sessions', () => { + expect(teamAgentsFromMountedChildren('Lead', [ + { id: 'sess_reviewer', title: 'Reviewer', role: 'reviewer', mandate: 'Review diffs' }, + ])).toEqual([ + { agentId: 'main', kind: 'main', name: 'Lead', parentAgentId: null }, + { + agentId: 'sess_reviewer', + kind: 'team', + name: 'Reviewer', + parentAgentId: 'main', + role: 'reviewer', + mandate: 'Review diffs', + mountedSessionId: 'sess_reviewer', + }, + ]); + }); +}); + describe('teamAgentsFromSessionMetadata', () => { it('returns an empty list when resume metadata has no agents', () => { expect(teamAgentsFromSessionMetadata(undefined)).toEqual([]); diff --git a/apps/nori-web/src/App.tsx b/apps/nori-web/src/App.tsx index 6f990697..5d706046 100644 --- a/apps/nori-web/src/App.tsx +++ b/apps/nori-web/src/App.tsx @@ -1,4 +1,4 @@ -import { Component, Fragment, lazy, Suspense, useCallback, useEffect, useRef, useState, type ErrorInfo, type ReactNode } from 'react'; +import { Component, Fragment, lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ErrorInfo, type ReactNode } from 'react'; import { CronJobPanel } from './components/CronJobPanel'; import { AccountCenter } from './components/AccountCenter'; import { CodeView } from './components/CodeView'; @@ -11,7 +11,7 @@ import { ProjectFolderPicker } from './components/ProjectFolderPicker'; import { useI18n } from './i18n'; import { modelThinkingOptions } from './utils/model-thinking'; import { sessionAgentDisplayName } from './utils/session-agent'; -import { sessionsForSidebar } from './utils/session-mount'; +import { parentSessionIdOf, sessionsForSidebar } from './utils/session-mount'; import { findAgentDiscussion } from './utils/team-discussion'; import { loadRewindLimit } from './rewindPreferences'; import type { ChatSlashCommandName } from './utils/chat-slash-commands'; @@ -335,12 +335,20 @@ export function App() { return () => { disposed = true; window.clearInterval(timer); }; }, [sessionId, agentTreeRevision]); const browserPermissions = useBrowserPermissions(); + const departmentAgents = useMemo( + () => departmentAgentsFromSessions(sessions, activeSession), + [sessions, activeSession], + ); + const displayAgents = useMemo( + () => mergeSessionAgents(sessionAgents, departmentAgents), + [sessionAgents, departmentAgents], + ); const sessionActiveAgentCount = countActiveAgents(activity, sessionId ?? undefined); - const sessionTreeTokens = sessionAgents.reduce((total, agent) => total + (agent.tokens ?? 0), 0); + const sessionTreeTokens = displayAgents.reduce((total, agent) => total + (agent.tokens ?? 0), 0); // 与当前 agent 相关的 Discuss 轮次:它自己主持的,或它作为成员参加的。 // 轮次是树里独立的节点,所以不能从被查看 agent 自己的节点上读当前发言人; // WS 事件给出的 discussionTurnAgentId 比轮询的树新,优先采用。 - const activeDiscussion = findAgentDiscussion(sessionAgents, activeAgentId, discussionTurnAgentId); + const activeDiscussion = findAgentDiscussion(displayAgents, activeAgentId, discussionTurnAgentId); const effectiveGlobalActiveAgentCount = countActiveAgents(activity); const sessionTitles = Object.fromEntries(sessions.map(session => [session.id, session.title || session.id])); @@ -564,10 +572,12 @@ export function App() { activeSessionId={sessionId ?? undefined} onOpenSession={(id) => { switchSession(id); - selectSessionAgent(null); setActiveView('chat'); closeSidebarOnNarrowViewport(); }} + onAskParentIdentity={async ({ parentSessionId, brief }) => ( + api.sessions.fillIdentity(parentSessionId, brief) + )} onGraphChanged={() => { void refreshSessions(); }} onCreateTopLevelSession={createMapTopLevelSession} onChooseProject={(options) => { @@ -618,7 +628,7 @@ export function App() { streamingTurnId={activeTurnId} activeAgentCount={sessionActiveAgentCount} activeAgentTokens={sessionTreeTokens} - sessionAgents={sessionAgents} + sessionAgents={displayAgents} departmentChat={departmentChat} discussion={activeDiscussion} departmentRevision={agentTreeRevision} @@ -646,19 +656,11 @@ export function App() { browserPermissionsOverride={browserPermissions.pending} onResolveBrowserPermissionOverride={browserPermissions.resolvePermission} onOpenApprovalSession={(sourceSessionId, sourceAgentId) => { - if (sourceAgentId && sourceAgentId !== 'main') { - setPendingAgentOpen({ - sessionId: sourceSessionId, - agent: { - agent_id: sourceAgentId, - kind: 'team', - status: 'unknown', - }, - }); - } else { - setPendingAgentOpen(null); - } - switchSession(sourceSessionId); + const mountedId = sessions.find(session => session.id === sourceAgentId)?.id + ?? sessionAgents.find(agent => agent.agent_id === sourceAgentId)?.mounted_session_id; + setPendingAgentOpen(null); + setActiveAgentSelection(null); + switchSession(mountedId ?? sourceSessionId); setActiveView('chat'); closeSidebarOnNarrowViewport(); }} @@ -749,12 +751,15 @@ export function App() { activeView={activeView} activeAgentId={activeAgentId} activeAgent={activeAgent} - agents={sessionAgents} + agents={displayAgents} + sessions={sessions} + sessionId={sessionId} sessionTitle={activeSession?.title} viewLabel={viewLabels[activeView]} locationLabel={tr('Current location', '当前位置')} onSelectAgent={selectSessionAgent} onSelectWorkspace={() => { setActiveView('chat'); selectSessionAgent(null); }} + onSelectSession={(id) => { switchSession(id); setActiveView('chat'); }} />
{activeSession?.title || tr('No session', '无会话')}
@@ -948,27 +953,88 @@ export function buildAgentBreadcrumb( return path; } +export function buildSessionBreadcrumb( + sessions: readonly Session[], + activeSessionId: string | null | undefined, +): Session[] { + if (!activeSessionId) return []; + const byId = new Map(sessions.map(session => [session.id, session])); + const path: Session[] = []; + const visited = new Set(); + let current = byId.get(activeSessionId); + while (current && !visited.has(current.id)) { + visited.add(current.id); + const parentId = parentSessionIdOf(current); + if (!parentId) break; + const parent = byId.get(parentId); + if (!parent) break; + path.unshift(parent); + current = parent; + } + return path; +} + +function departmentAgentsFromSessions( + sessions: readonly Session[], + active: Session | null, +): SessionAgent[] { + if (active === null) return []; + const parentId = parentSessionIdOf(active) ?? active.id; + return sessions + .filter(session => parentSessionIdOf(session) === parentId) + .map(session => ({ + agent_id: session.id, + kind: 'team' as const, + parent_agent_id: 'main', + name: session.title || session.id, + role: typeof session.metadata?.mount_role === 'string' ? session.metadata.mount_role : undefined, + mandate: typeof session.metadata?.mount_mandate === 'string' ? session.metadata.mount_mandate : undefined, + status: session.status, + mounted_session_id: session.id, + })); +} + +function mergeSessionAgents( + agents: readonly SessionAgent[], + department: readonly SessionAgent[], +): SessionAgent[] { + const byId = new Map(); + for (const agent of agents) byId.set(agent.agent_id, agent); + for (const agent of department) { + const existing = byId.get(agent.agent_id); + byId.set(agent.agent_id, existing === undefined ? agent : { ...agent, ...existing }); + } + return [...byId.values()]; +} + export function AgentBreadcrumb({ activeView, activeAgentId, activeAgent, agents, + sessions = [], + sessionId, sessionTitle, viewLabel, locationLabel, onSelectAgent, onSelectWorkspace, + onSelectSession, }: { activeView: View; activeAgentId: string; activeAgent: SessionAgent | null; agents: readonly SessionAgent[]; + sessions?: readonly Session[]; + sessionId?: string | null; sessionTitle?: string; viewLabel: string; locationLabel: string; onSelectAgent: (agent: SessionAgent | null) => void; onSelectWorkspace: () => void; + onSelectSession?: (sessionId: string) => void; }) { + const sessionPath = buildSessionBreadcrumb(sessions, sessionId); const path = buildAgentBreadcrumb(agents, activeAgentId); const currentAgent = activeAgentId === 'main' ? null @@ -979,9 +1045,19 @@ export function AgentBreadcrumb({ return
+ {activeView === 'chat' && sessionPath.map(session => ( + + + + + ))} {activeView === 'chat' && sessionTitle ? - : {viewLabel}} + : activeView !== 'chat' ? {viewLabel} : {viewLabel}} {activeView === 'chat' && visibleAgents.map(agent => diff --git a/apps/nori-web/src/api/client.ts b/apps/nori-web/src/api/client.ts index 07bd98bf..6b265351 100644 --- a/apps/nori-web/src/api/client.ts +++ b/apps/nori-web/src/api/client.ts @@ -495,6 +495,7 @@ export interface TeamChatMessage { export interface SessionAgentChatResponse { department_leader_agent_id: string | null; + department_leader_session_id?: string; messages: TeamChatMessage[]; } @@ -1119,6 +1120,13 @@ export function createClient( { method: 'PATCH', body: patch }, ), + fillIdentity: (id: string, brief: string) => + request<{ title: string; role: string; mandate: string }>( + `/sessions/${encodeURIComponent(id)}:fill-identity`, + undefined, + { method: 'POST', body: { brief } }, + ), + fork: (id: string, title?: string) => request( `/sessions/${encodeURIComponent(id)}:fork`, undefined, diff --git a/apps/nori-web/src/components/CodeView.tsx b/apps/nori-web/src/components/CodeView.tsx index caf84e6d..ee60035f 100644 --- a/apps/nori-web/src/components/CodeView.tsx +++ b/apps/nori-web/src/components/CodeView.tsx @@ -328,7 +328,7 @@ export function CodeView({ isStreaming={isStreaming} activeAgentCount={activeAgentCount} mainWorking={isStreaming} - selfAgentId={agentId} + selfAgentId={session?.id ?? agentId} sessionAgents={sessionAgents} departmentChat={departmentChat} discussion={discussion} diff --git a/apps/nori-web/src/components/SessionAgentTree.tsx b/apps/nori-web/src/components/SessionAgentTree.tsx deleted file mode 100644 index 13910f9d..00000000 --- a/apps/nori-web/src/components/SessionAgentTree.tsx +++ /dev/null @@ -1,590 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'; -import { createPortal } from 'react-dom'; -import { api, type BackgroundTask, type SessionAgent, type SessionRealtimeStatus } from '../api/client'; -import { useI18n } from '../i18n'; -import { sessionAgentDisplayName } from '../utils/session-agent'; -import { findAgentDiscussion } from '../utils/team-discussion'; -import { Icon } from './Icon'; - -interface SessionAgentTreeProps { - sessionId: string | null; - selectedAgentId: string; - backgroundTasks: readonly BackgroundTask[]; - backgroundLoading?: boolean; - backgroundError?: string | null; - hasGlobalActivity?: boolean; - sessionStatus?: SessionRealtimeStatus | null; - agentTreeRevision?: number; - discussionTurnAgentId?: string | null; - onSelectAgent: (agent: SessionAgent | null) => void; - onAgentsChange?: (agents: readonly SessionAgent[]) => void; - onBackgroundTaskCancelled?: (taskId: string) => void; -} - -const TERMINAL_STATUSES = new Set(['completed', 'done', 'failed', 'cancelled', 'stopped', 'terminated']); -const ACTIVE_STATUSES = new Set(['running', 'active', 'pending', 'awaiting_approval', 'awaiting_question']); -const EMPTY_AGENT_CHILDREN = new Map(); - -function isTerminalAgent(agent: SessionAgent): boolean { - return TERMINAL_STATUSES.has(agentStatus(agent)); -} - -function isActiveAgent(agent: SessionAgent): boolean { - return ACTIVE_STATUSES.has(agentStatus(agent)); -} - -export function isActiveDiscussionAgent(agent: SessionAgent): boolean { - const status = agentStatus(agent); - return (agent.kind === 'discussion' || status === 'discuss') - && !agent.archived - && status !== 'archived' - && !isTerminalAgent(agent); -} - -export function activeDiscussionForAgent( - agents: readonly SessionAgent[], - selectedAgentId: string, - _sessionStatus?: SessionRealtimeStatus | null, -): boolean { - const byId = new Map(agents.map(agent => [agent.agent_id, agent])); - const discussions = agents.filter(isActiveDiscussionAgent); - if (discussions.length === 0) return false; - if (selectedAgentId === 'main') return true; - const isInBranch = (startId: string, targetId: string): boolean => { - let current = byId.get(startId); - const visited = new Set(); - while (current && current.agent_id !== 'main' && !visited.has(current.agent_id)) { - if (current.agent_id === targetId) return true; - visited.add(current.agent_id); - current = current.parent_agent_id ? byId.get(current.parent_agent_id) : undefined; - } - return false; - }; - return discussions.some(discussion => { - return isInBranch(selectedAgentId, discussion.agent_id) - || isInBranch(discussion.agent_id, selectedAgentId); - }); -} - -function agentStatus(agent: SessionAgent): string { - return typeof agent.status === 'string' ? agent.status.toLowerCase() : ''; -} - -function agentLabel(agent: SessionAgent): string { - return sessionAgentDisplayName(agent); -} - -function humanSummary(value: string | undefined): string | undefined { - if (!value) return undefined; - const compact = value - .replace(/<[^>]*>/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - if (!compact) return undefined; - return compact.length <= 128 ? compact : `${compact.slice(0, 127).trimEnd()}…`; -} - -function relativeTime(value: string | undefined): string | undefined { - if (!value) return undefined; - const timestamp = Date.parse(value); - if (!Number.isFinite(timestamp)) return undefined; - const seconds = Math.max(0, Math.round((Date.now() - timestamp) / 1_000)); - if (seconds < 60) return 'now'; - if (seconds < 3_600) return `${Math.floor(seconds / 60)}m`; - if (seconds < 86_400) return `${Math.floor(seconds / 3_600)}h`; - return `${Math.floor(seconds / 86_400)}d`; -} - -function agentStatusClass(status: string): string { - const normalized = status.toLowerCase(); - if (normalized === 'running' || normalized === 'active') return 'active'; - if (normalized === 'failed') return 'failed'; - if (normalized === 'paused') return 'paused'; - if (normalized === 'idle' || normalized === 'aborted') return 'idle'; - if (TERMINAL_STATUSES.has(normalized)) return 'idle'; - return 'pending'; -} - -interface AgentTreeData { - teamPartners: SessionAgent[]; - activeDiscussions: SessionAgent[]; - independentRoots: SessionAgent[]; - archivedAgents: SessionAgent[]; - teamChildrenByParent: Map; - independentChildrenByParent: Map; - archivedChildrenByParent: Map; -} - -function buildChildrenByParent( - agents: readonly SessionAgent[], - allowedIds: ReadonlySet, -): Map { - const candidateById = new Map(); - for (const agent of agents) { - const candidate = typeof agent.parent_agent_id === 'string' ? agent.parent_agent_id : undefined; - if ( - candidate - && candidate !== agent.agent_id - && (candidate === 'main' || allowedIds.has(candidate)) - ) { - candidateById.set(agent.agent_id, candidate); - } - } - - const createsCycle = (agentId: string, candidate: string): boolean => { - const visited = new Set([agentId]); - let current: string | undefined = candidate; - while (current && current !== 'main') { - if (visited.has(current)) return true; - visited.add(current); - current = candidateById.get(current); - } - return false; - }; - - const childrenByParent = new Map(); - for (const agent of agents) { - const candidate = candidateById.get(agent.agent_id); - const parent = candidate && !createsCycle(agent.agent_id, candidate) ? candidate : 'main'; - const children = childrenByParent.get(parent) ?? []; - children.push(agent); - childrenByParent.set(parent, children); - } - return childrenByParent; -} - -function buildAgentTreeData(agents: readonly SessionAgent[]): AgentTreeData { - const uniqueAgents: SessionAgent[] = []; - const seenIds = new Set(); - for (const agent of agents) { - if (!agent || typeof agent.agent_id !== 'string' || !agent.agent_id || seenIds.has(agent.agent_id)) continue; - seenIds.add(agent.agent_id); - uniqueAgents.push(agent); - } - - const nonMainAgents = uniqueAgents.filter(agent => agent.agent_id !== 'main' && agent.kind !== 'main'); - const archived = nonMainAgents.filter(agent => ( - agent.archived - || agentStatus(agent) === 'archived' - || (agent.kind === 'discussion' && isTerminalAgent(agent)) - )); - const active = nonMainAgents.filter(agent => !archived.includes(agent)); - const activeIds = new Set(active.map(agent => agent.agent_id)); - const archivedIds = new Set(archived.map(agent => agent.agent_id)); - const activeChildrenByParent = buildChildrenByParent(active, activeIds); - const archivedChildrenByParent = buildChildrenByParent(archived, archivedIds); - const activeRoots = activeChildrenByParent.get('main') ?? []; - const teamPartners = activeRoots.filter(agent => agent.kind === 'team'); - const activeDiscussions = activeRoots.filter(agent => agent.kind === 'discussion'); - const independentRoots = activeRoots.filter(agent => agent.kind !== 'team' && agent.kind !== 'discussion'); - const teamChildrenByParent = new Map(activeChildrenByParent); - const independentChildrenByParent = new Map(activeChildrenByParent); - teamChildrenByParent.set('main', teamPartners); - independentChildrenByParent.set('main', independentRoots); - - return { - teamPartners, - activeDiscussions, - independentRoots, - archivedAgents: archivedChildrenByParent.get('main') ?? [], - teamChildrenByParent, - independentChildrenByParent, - archivedChildrenByParent, - }; -} - -export function SessionAgentTree({ - sessionId, - selectedAgentId, - backgroundTasks, - backgroundLoading = false, - backgroundError = null, - hasGlobalActivity = false, - sessionStatus: _sessionStatus = null, - agentTreeRevision = 0, - discussionTurnAgentId, - onSelectAgent, - onAgentsChange, - onBackgroundTaskCancelled, -}: SessionAgentTreeProps) { - const { tr } = useI18n(); - const [agents, setAgents] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [open, setOpen] = useState(false); - const [menuStyle, setMenuStyle] = useState(); - const requestIdRef = useRef(0); - const treeRef = useRef(null); - const menuRef = useRef(null); - const loadedSessionIdRef = useRef(null); - const openRef = useRef(false); - const refreshDeferredRef = useRef(false); - const agentsRef = useRef([]); - const [collapsedAgentIds, setCollapsedAgentIds] = useState>(() => new Set()); - const [sectionOpen, setSectionOpen] = useState({ team: true, discussion: true, independent: true, background: false, archive: false }); - - const refresh = useCallback(async (force = false) => { - if (!sessionId) { - loadedSessionIdRef.current = null; - agentsRef.current = []; - setAgents([]); - setError(null); - onAgentsChange?.([]); - return; - } - if (!force && openRef.current && loadedSessionIdRef.current === sessionId) { - refreshDeferredRef.current = true; - return; - } - if (loadedSessionIdRef.current !== sessionId) { - loadedSessionIdRef.current = sessionId; - agentsRef.current = []; - setAgents([]); - setError(null); - } - const requestId = ++requestIdRef.current; - setLoading(true); - try { - const result = await api.sessions.getAgents(sessionId); - if (requestId !== requestIdRef.current) return; - const items = result.items ?? []; - if (!sameAgentList(agentsRef.current, items)) { - agentsRef.current = items; - setAgents(items); - onAgentsChange?.(items); - } - setError(null); - if (selectedAgentId !== 'main' && !items.some(agent => agent.agent_id === selectedAgentId)) { - onSelectAgent(null); - } - } catch (caught) { - if (requestId !== requestIdRef.current) return; - setError(caught instanceof Error ? caught.message : String(caught)); - } finally { - if (requestId === requestIdRef.current) setLoading(false); - } - }, [onAgentsChange, onSelectAgent, selectedAgentId, sessionId]); - - useEffect(() => { - openRef.current = false; - refreshDeferredRef.current = false; - setCollapsedAgentIds(new Set()); - setSectionOpen({ team: true, discussion: true, independent: true, background: false, archive: false }); - }, [sessionId]); - - useEffect(() => { - void refresh(); - if (!sessionId) return; - const interval = window.setInterval(() => { void refresh(); }, 4_000); - return () => { - window.clearInterval(interval); - requestIdRef.current++; - }; - }, [refresh, sessionId]); - - useEffect(() => { - if (!sessionId || agentTreeRevision === 0) return; - // Surface the first lifecycle event while the tree is open; once a - // hierarchy exists, defer refresh until close so native
state - // is not rebuilt under the user's cursor. - void refresh(agentsRef.current.length <= 1); - }, [agentTreeRevision, refresh, sessionId]); - - const { - teamPartners, - activeDiscussions, - independentRoots, - archivedAgents, - teamChildrenByParent, - independentChildrenByParent, - archivedChildrenByParent, - } = useMemo(() => buildAgentTreeData(agents), [agents]); - - const mainAgent = useMemo(() => { - if (!sessionId) return null; - return agents.find(agent => agent.agent_id === 'main' || agent.kind === 'main') ?? { - agent_id: 'main', - kind: 'main', - name: tr('Main session', '主会话'), - status: 'idle', - }; - }, [agents, sessionId, tr]); - const hasActivity = hasGlobalActivity - || teamPartners.some(isActiveAgent) - || activeDiscussions.some(isActiveAgent) - || independentRoots.some(isActiveAgent) - || [...teamChildrenByParent.values()].some(list => list.some(isActiveAgent)) - || [...independentChildrenByParent.values()].some(list => list.some(isActiveAgent)) - || backgroundTasks.some(task => task.status === 'running'); - const discussionActive = activeDiscussionForAgent(agents, selectedAgentId, _sessionStatus); - const currentDiscussionTurnAgentId = findAgentDiscussion(agents, selectedAgentId, discussionTurnAgentId)?.turnAgentId - ?? activeDiscussions.find(agent => agent.discussion_turn_agent_id)?.discussion_turn_agent_id; - const currentDiscussionTurnAgent = currentDiscussionTurnAgentId === undefined - ? undefined - : agents.find(agent => agent.agent_id === currentDiscussionTurnAgentId); - const closeTree = useCallback(() => { - treeRef.current?.removeAttribute('open'); - setOpen(false); - }, []); - const readMenuPosition = useCallback((): CSSProperties | undefined => { - const trigger = treeRef.current?.querySelector('summary'); - if (!trigger) return undefined; - const bounds = trigger.getBoundingClientRect(); - const viewportMargin = 16; - const width = Math.min(440, Math.max(280, window.innerWidth - viewportMargin * 2)); - const right = Math.max(viewportMargin, window.innerWidth - bounds.right); - return { - top: bounds.bottom + 6, - right, - width, - maxHeight: Math.max(160, Math.min(window.innerHeight - bounds.bottom - 22, window.innerHeight * 0.7)), - }; - }, []); - const updateMenuPosition = useCallback(() => { - setMenuStyle(readMenuPosition()); - }, [readMenuPosition]); - useEffect(() => { - if (!open) return; - updateMenuPosition(); - const closeOnOutsidePointer = (event: PointerEvent) => { - const target = event.target as Node | null; - if (target && (treeRef.current?.contains(target) || menuRef.current?.contains(target))) return; - closeTree(); - }; - const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') closeTree(); - }; - window.addEventListener('resize', updateMenuPosition); - window.addEventListener('scroll', updateMenuPosition, true); - document.addEventListener('pointerdown', closeOnOutsidePointer, true); - document.addEventListener('keydown', closeOnEscape); - return () => { - window.removeEventListener('resize', updateMenuPosition); - window.removeEventListener('scroll', updateMenuPosition, true); - document.removeEventListener('pointerdown', closeOnOutsidePointer, true); - document.removeEventListener('keydown', closeOnEscape); - }; - }, [closeTree, open, updateMenuPosition]); - const selectAgent = useCallback((agent: SessionAgent | null) => { - closeTree(); - onSelectAgent(agent); - }, [closeTree, onSelectAgent]); - - const toggleAgent = useCallback((agentId: string) => { - setCollapsedAgentIds(previous => { - const next = new Set(previous); - if (next.has(agentId)) next.delete(agentId); - else next.add(agentId); - return next; - }); - }, []); - const updateSectionOpen = useCallback((section: 'team' | 'discussion' | 'independent' | 'background' | 'archive', nextOpen: boolean) => { - setSectionOpen(previous => previous[section] === nextOpen ? previous : { ...previous, [section]: nextOpen }); - }, []); - - const treeCounts = { - team: teamPartners.length, - discussion: activeDiscussions.length, - independent: independentRoots.length, - background: backgroundTasks.length, - archive: archivedAgents.length, - }; - const mainChildrenContent = (treeCounts.team > 0 || treeCounts.discussion > 0 || treeCounts.independent > 0) ? <> - {treeCounts.team > 0 && updateSectionOpen('team', nextOpen)}> - {teamPartners.map(agent => )}} - {treeCounts.discussion > 0 && updateSectionOpen('discussion', nextOpen)}> - {activeDiscussions.map(agent => )}} - {treeCounts.independent > 0 && updateSectionOpen('independent', nextOpen)}> - {independentRoots.map(agent => )}} - : undefined; - const menu =
-
-
- - {tr('Session agents', '会话智能体')}{tr('Main session and branches', '主会话与分支')} -
-
- {discussionActive ? tr('Discuss', '讨论') : tr('Code', '执行')} - {currentDiscussionTurnAgent &&