diff --git a/.changeset/unify-session-team-engineering.md b/.changeset/unify-session-team-engineering.md new file mode 100644 index 00000000..ef074f26 --- /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. In the terminal, `/team` and `/map` keep the live department snapshot, and Ctrl-Y shows Chat on the lead 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..c7cb9483 100644 --- a/apps/nori-code/src/tui/commands/map.ts +++ b/apps/nori-code/src/tui/commands/map.ts @@ -3,12 +3,10 @@ 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, sessionMapLabel } from '../utils/session-map-tree'; +import { buildDepartmentSnapshot } from '../utils/team-tree'; import type { SlashCommandHost } from './dispatch'; -type MappedTeamAgent = TeamAgentSnapshot & { readonly hostSessionId: string }; - export async function handleMapCommand(host: SlashCommandHost): Promise { let graph: SessionGraphSummary; try { @@ -18,7 +16,6 @@ export async function handleMapCommand(host: SlashCommandHost): Promise { return; } - let mappedTeamAgents: MappedTeamAgent[] = []; const render = (): void => { host.mountEditorReplacement( new SessionMapBrowserComponent({ @@ -26,49 +23,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); + 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); + refreshTeamAgents(host, graph); render(); }, render); }, @@ -79,65 +46,25 @@ export async function handleMapCommand(host: SlashCommandHost): Promise { ); }; - mappedTeamAgents = await refreshTeamAgents(host, graph.nodes); + refreshTeamAgents(host, graph); render(); } -async function refreshTeamAgents( - host: SlashCommandHost, - nodes: readonly SessionSummary[], -): 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. - } - } +function refreshTeamAgents(host: SlashCommandHost, graph: SessionGraphSummary): void { const currentSessionId = host.session?.id; + if (currentSessionId === undefined) return; + const hostNode = graph.nodes.find((node) => node.id === currentSessionId); host.setAppState({ - teamAgents: mapped.filter((agent) => agent.hostSessionId === currentSessionId), + teamAgents: buildDepartmentSnapshot({ + hostSessionId: currentSessionId, + hostTitle: hostNode === undefined + ? (host.state.appState.sessionTitle ?? 'Main') + : sessionMapLabel(hostNode), + graph, + metadata: host.session?.getResumeState()?.sessionMetadata, + live: host.state.appState.teamAgents, + }), }); - 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( diff --git a/apps/nori-code/src/tui/commands/team.ts b/apps/nori-code/src/tui/commands/team.ts index 7960fb00..576d3156 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, buildDepartmentSnapshot, teamMemberSessionId, type TeamAgentSnapshot } from '../utils/team-tree'; +import { 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,32 @@ 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); + host.setAppState({ + teamAgents: buildDepartmentSnapshot({ + hostSessionId: sessionId, + hostTitle: hostNode === undefined + ? (host.state.appState.sessionTitle ?? 'Main') + : sessionMapLabel(hostNode), + graph, + metadata: host.session?.getResumeState()?.sessionMetadata, + live: host.state.appState.teamAgents, + }), + }); + } catch (error) { + host.showError(formatErrorMessage(error)); + } +} + function showTeamBrowser(host: SlashCommandHost): void { host.mountEditorReplacement( new TeamBrowserComponent({ @@ -22,7 +47,32 @@ function showTeamBrowser(host: SlashCommandHost): void { discussMode: host.state.appState.discussMode, currentAgentId: currentViewingAgentId(host.state.appState.viewingAgentId), onSelect: (agent) => { - void host.teamViewController.open(agent); + if (agent.kind === 'main') { + host.restoreEditor(); + return; + } + if (agent.kind === 'discussion') { + host.restoreEditor(); + host.teamViewController.reveal(); + host.showStatus(host.state.appState.discussMode ? 'Opened Discuss' : 'Opened Chat'); + return; + } + const sessionId = teamMemberSessionId(agent); + if (sessionId === undefined) { + host.showError('This member is no longer a session.'); + showTeamBrowser(host); + return; + } + void (async () => { + try { + const session = await host.harness.resumeSession({ id: sessionId }); + await host.switchToSession(session, `Opened session (${session.id}).`); + host.restoreEditor(); + } catch (error) { + host.showError(String(error)); + showTeamBrowser(host); + } + })(); }, onDetails: (agent) => { showTeamMemberDetail(host, agent); diff --git a/apps/nori-code/src/tui/components/chrome/footer.ts b/apps/nori-code/src/tui/components/chrome/footer.ts index 801fff40..9534afa8 100644 --- a/apps/nori-code/src/tui/components/chrome/footer.ts +++ b/apps/nori-code/src/tui/components/chrome/footer.ts @@ -274,11 +274,14 @@ export class FooterComponent implements Component { if (hired > 0) { core.push(chalk.hex(colors.textMuted)(`[team ${String(hired)}]`)); } - const viewingId = state.viewingAgentId; - if (viewingId !== undefined && viewingId !== 'main') { - const viewingName = - state.teamAgents.find((agent) => agent.agentId === viewingId)?.name ?? viewingId; - core.push(chalk.hex(colors.primary).bold(viewingName)); + const parentId = state.parentSessionId; + if (parentId !== undefined && parentId.length > 0) { + const mountedName = state.sessionTitle?.trim(); + core.push( + chalk.hex(colors.primary).bold( + mountedName !== undefined && mountedName.length > 0 ? mountedName : 'member', + ), + ); } const speaking = teamSpeakingLabel(state.teamAgents); if (speaking !== undefined) { diff --git a/apps/nori-code/src/tui/components/panes/department-pane.ts b/apps/nori-code/src/tui/components/panes/department-pane.ts index fe4c79ea..afcaaf43 100644 --- a/apps/nori-code/src/tui/components/panes/department-pane.ts +++ b/apps/nori-code/src/tui/components/panes/department-pane.ts @@ -2,7 +2,7 @@ * Department pane — read-only Discuss meeting track or sibling Chat. * * Discuss on: forced meeting UI. Discuss off: Chat. Closing the pane does not - * exit Discuss or the viewed member session. + * exit Discuss or the current session. */ import { Container, truncateToWidth, visibleWidth } from '@nori-code/pi-tui'; diff --git a/apps/nori-code/src/tui/controllers/session-event-handler.ts b/apps/nori-code/src/tui/controllers/session-event-handler.ts index 71f0dbef..0f207662 100644 --- a/apps/nori-code/src/tui/controllers/session-event-handler.ts +++ b/apps/nori-code/src/tui/controllers/session-event-handler.ts @@ -89,7 +89,7 @@ import { extractTeamSpeechText, isTeamSpeechTool, shouldPaintDiscussUtterance, - teamAgentsFromSessionMetadata, + buildDepartmentSnapshot, type TeamAgentSnapshot, type TeamReportStatus, } from '../utils/team-tree'; @@ -968,7 +968,12 @@ export class SessionEventHandler { this.host.updateTerminalTitle(); } if (event.patch?.['agents'] !== undefined) { - this.setTeamAgents(teamAgentsFromSessionMetadata(event.patch)); + this.setTeamAgents(buildDepartmentSnapshot({ + hostTitle: this.host.state.appState.sessionTitle ?? 'Main', + hostSessionId: this.host.state.appState.sessionId, + metadata: event.patch, + live: this.host.state.appState.teamAgents, + })); } } @@ -1004,8 +1009,12 @@ export class SessionEventHandler { } seedTeamAgentsFromSession(session: Session): void { - const agents = teamAgentsFromSessionMetadata(session.getResumeState()?.sessionMetadata); - this.host.setAppState({ teamAgents: agents }); + this.setTeamAgents(buildDepartmentSnapshot({ + hostTitle: session.summary?.title ?? this.host.state.appState.sessionTitle ?? 'Main', + hostSessionId: session.id, + metadata: session.getResumeState()?.sessionMetadata, + live: this.host.state.appState.teamAgents, + })); this.host.teamViewController.seedFromSession(session); } diff --git a/apps/nori-code/src/tui/controllers/team-view.ts b/apps/nori-code/src/tui/controllers/team-view.ts index a6fa89ea..b3abb84e 100644 --- a/apps/nori-code/src/tui/controllers/team-view.ts +++ b/apps/nori-code/src/tui/controllers/team-view.ts @@ -6,14 +6,15 @@ import { type DepartmentPaneLine, type DepartmentPaneModel, } from '../components/panes/department-pane'; -import { MAIN_AGENT_ID, NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { MAIN_AGENT_ID } from '../constant/kimi-tui'; import type { ColorToken } from '../theme'; import type { AppState } from '../types'; import type { TUIState } from '../tui-state'; import { argsRecord } from '../utils/event-payload'; import { currentViewingAgentId, - departmentChatLeaderId, + departmentChatOwnerId, + departmentPaneEmptyHint, departmentPaneMode, extractTeamChatPost, extractTeamSpeechText, @@ -21,22 +22,20 @@ import { isTeamSpeechTool, shouldPaintDiscussUtterance, teamChatMessagesFromMetadata, + teamMemberCount, upsertTeamChatMessage, type TeamAgentSnapshot, type TeamChatMessage, } from '../utils/team-tree'; -import type { SessionReplayRenderer } from './session-replay'; export interface TeamViewHost { state: TUIState; session: Session | undefined; readonly harness: KimiHarness; - readonly sessionReplay: SessionReplayRenderer; setAppState(patch: Partial): void; showStatus(msg: string, color?: ColorToken): void; showError(msg: string): void; restoreEditor(): void; - prepareTranscriptForAgentView(agentId: string): void; } interface SpeechDraft { @@ -120,70 +119,13 @@ export class TeamViewController { seedFromSession(session: Session): void { const metadata = session.getResumeState()?.sessionMetadata; - const leaderId = this.chatLeaderId(); - if (leaderId !== undefined) { - this.chatMessages = teamChatMessagesFromMetadata(metadata, leaderId); - for (const message of this.chatMessages) { - this.nextLiveChatId = Math.max(this.nextLiveChatId, message.messageId + 1); - } - } else { - this.chatMessages = []; + this.chatMessages = teamChatMessagesFromMetadata(metadata, departmentChatOwnerId()); + for (const message of this.chatMessages) { + this.nextLiveChatId = Math.max(this.nextLiveChatId, message.messageId + 1); } this.refreshPane(); } - async open(agent: TeamAgentSnapshot): Promise { - this.host.restoreEditor(); - if (agent.kind === 'discussion') { - this.reveal(); - this.host.showStatus( - this.host.state.appState.discussMode ? 'Opened Discuss' : 'Opened Chat', - ); - return; - } - if (agent.kind !== 'main') { - const current = this.host.state.appState.teamAgents.find( - (candidate) => candidate.agentId === agent.agentId, - ); - if (agent.kind !== 'team' || current?.kind !== 'team' || current.archived === true) { - this.host.showError(`Team member "${agent.name}" is no longer available.`); - return; - } - } - const agentId = agent.kind === 'main' ? MAIN_AGENT_ID : agent.agentId; - await this.switchTo(agentId, agent.name); - } - - async switchTo(agentId: string, name: string): Promise { - const session = this.host.session; - if (session === undefined) { - this.host.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } - if ( - agentId !== MAIN_AGENT_ID - && !this.host.state.appState.teamAgents.some((agent) => - agent.agentId === agentId - && agent.kind === 'team' - && agent.archived !== true, - ) - ) { - this.host.showError(`Team member "${name}" is no longer available.`); - return; - } - const previous = this.viewingAgentId(); - if (previous !== agentId) { - this.host.setAppState({ viewingAgentId: agentId }); - this.host.prepareTranscriptForAgentView(agentId); - await this.host.sessionReplay.hydrateFromReplay(session, agentId); - } - this.seedFromSession(session); - if (agentId !== MAIN_AGENT_ID || this.host.state.appState.discussMode) { - this.reveal(); - } - this.host.showStatus(agentId === MAIN_AGENT_ID ? 'Viewing Main' : `Viewing ${name}`); - } - routeEvent(event: Event): void { if (event.type === 'session.meta.updated' && event.patch?.['agents'] !== undefined) { const session = this.host.session; @@ -283,14 +225,6 @@ export class TeamViewController { this.nextSpeechId += 1; } - private chatLeaderId(): string | undefined { - const viewing = this.viewingAgentId(); - const agents = this.host.state.appState.teamAgents; - const self = agents.find((agent) => agent.agentId === viewing); - if (self !== undefined) return departmentChatLeaderId(self); - return viewing === MAIN_AGENT_ID ? undefined : MAIN_AGENT_ID; - } - private mount(): void { const pane = new DepartmentPaneComponent(this.paneModel(), { terminalRows: () => this.host.state.terminal.rows, @@ -316,7 +250,12 @@ export class TeamViewController { private paneModel(): DepartmentPaneModel { const mode = departmentPaneMode(this.host.state.appState.discussMode); const agents = this.host.state.appState.teamAgents; - const discussion = findAgentDiscussion(agents, this.viewingAgentId()); + const discussion = findAgentDiscussion(agents, MAIN_AGENT_ID); + const emptyHint = departmentPaneEmptyHint({ + mode, + hasMembers: teamMemberCount(agents) > 0, + parentSessionId: this.host.state.appState.parentSessionId, + }); if (mode === 'discuss') { const lines = this.speechLines.flatMap((draft) => { if (draft.text.trim().length === 0) return []; @@ -338,7 +277,7 @@ export class TeamViewController { topic: discussion?.topic, speakingName: lines.some((line) => line.speaking === true) ? undefined : speakingName, lines, - emptyHint: 'No statements in this round yet.', + emptyHint, }; } const lines: DepartmentPaneLine[] = this.chatMessages.map((message) => ({ @@ -350,10 +289,7 @@ export class TeamViewController { return { mode, lines, - emptyHint: - this.chatLeaderId() === undefined - ? 'Chat is for department members. Open a partner from /team.' - : 'No messages yet — members align here while working.', + emptyHint, }; } } diff --git a/apps/nori-code/src/tui/kimi-tui.ts b/apps/nori-code/src/tui/kimi-tui.ts index b23d73fb..31aeab25 100644 --- a/apps/nori-code/src/tui/kimi-tui.ts +++ b/apps/nori-code/src/tui/kimi-tui.ts @@ -133,7 +133,8 @@ import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attach import { extractMediaAttachments } from './utils/image-placeholder'; import { hasPatchChanges } from './utils/object-patch'; import { sessionRowsForPicker } from './utils/session-picker-rows'; -import { teamAgentsFromSessionMetadata } from './utils/team-tree'; +import { parentSessionIdOf } from './utils/session-map-tree'; +import { buildDepartmentSnapshot } from './utils/team-tree'; import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup'; import { thinkingEffortFromConfig } from './utils/thinking-config'; import { installTerminalFocusTracking } from './utils/terminal-focus'; @@ -220,6 +221,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { mcpServersSummary: null, teamAgents: [], viewingAgentId: 'main', + parentSessionId: undefined, banner: undefined, }; } @@ -1575,6 +1577,19 @@ export class KimiTUI { async syncRuntimeState(session: Session = this.requireSession()): Promise { const [status, goalResult] = await Promise.all([session.getStatus(), session.getGoal()]); + let graph: Awaited> | undefined; + try { + graph = await this.harness.getSessionGraph({ workDir: this.state.appState.workDir }); + } catch { + // Graph is best-effort; Discuss nodes still come from resume metadata. + } + const forest = buildDepartmentSnapshot({ + hostSessionId: session.id, + hostTitle: session.summary?.title ?? this.state.appState.sessionTitle ?? 'Main', + metadata: session.getResumeState()?.sessionMetadata, + graph, + live: this.state.appState.teamAgents, + }); this.setAppState({ sessionId: session.id, model: status.model ?? '', @@ -1588,7 +1603,10 @@ export class KimiTUI { contextUsage: status.contextUsage, sessionTitle: session.summary?.title ?? null, goal: goalResult.goal, - teamAgents: teamAgentsFromSessionMetadata(session.getResumeState()?.sessionMetadata), + teamAgents: forest, + parentSessionId: parentSessionIdOf( + session.summary?.metadata as Record | undefined, + ), }); this.teamViewController.seedFromSession(session); this.syncAdditionalDirs(session); @@ -1713,7 +1731,7 @@ export class KimiTUI { this.state.footer.setBackgroundCounts({ processTasks: 0, questionTasks: 0 }); this.streamingUI.setTodoList([]); this.streamingUI.setTurnId(undefined); - this.setAppState({ mcpServersSummary: null }); + this.setAppState({ mcpServersSummary: null, teamAgents: [], parentSessionId: undefined, viewingAgentId: 'main' }); this.streamingUI.setStep(0); this.streamingUI.resetLiveText(); this.updateQueueDisplay(); @@ -2040,22 +2058,6 @@ export class KimiTUI { this.renderWelcome(); } - prepareTranscriptForAgentView(agentId: string): void { - this.streamingUI.discardPending(); - this.state.transcriptEntries = []; - this.streamingUI.disposeActiveCompactionBlock(); - this.streamingUI.resetLiveText(); - this.streamingUI.resetToolUi(); - this.sessionEventHandler.stopAllMcpServerStatusSpinners(); - this.disposeTranscriptChildren(); - this.state.transcriptContainer.clear(); - this.clearTerminalInlineImages(); - this.state.todoPanel.clear(); - this.state.todoPanelContainer.clear(); - this.imageStore.clear(); - if (agentId === MAIN_AGENT_ID) this.renderWelcome(); - } - private isTurnBoundaryComponent(child: Component): boolean { if ( !(child instanceof UserMessageComponent) && diff --git a/apps/nori-code/src/tui/types.ts b/apps/nori-code/src/tui/types.ts index ffbc3674..13b4f134 100644 --- a/apps/nori-code/src/tui/types.ts +++ b/apps/nori-code/src/tui/types.ts @@ -64,6 +64,8 @@ export interface AppState { teamAgents: readonly TeamAgentSnapshot[]; /** Agent whose session the main transcript is showing. Defaults to main. */ viewingAgentId?: string; + /** Parent session id when this session is a mounted department member. */ + parentSessionId?: string; /** Optional banner shown below the welcome panel; null means no banner to render. */ banner?: BannerState | null; } diff --git a/apps/nori-code/src/tui/utils/team-tree.ts b/apps/nori-code/src/tui/utils/team-tree.ts index 3b09af7c..a5532fd2 100644 --- a/apps/nori-code/src/tui/utils/team-tree.ts +++ b/apps/nori-code/src/tui/utils/team-tree.ts @@ -1,10 +1,9 @@ /** * Team-engineering snapshot helpers for the TUI. * - * The CLI cannot call agent-core `listAgents`. It reconstructs a department - * tree from session resume metadata, live `agent.status.updated` / - * `discussion.updated` events, and Team* tool results — enough to browse - * members, roles, and reports in a terminal list. + * Durable members are mounted child Sessions. Resume metadata may still carry + * leftover parent-session team shadows and Discuss nodes; the forest comes + * from `getSessionGraph`, live Team* results, and status events. */ export type TeamAgentKind = 'main' | 'team' | 'discussion' | 'independent'; @@ -119,7 +118,9 @@ export function teamAgentsFromSessionMetadata(metadata: unknown): TeamAgentSnaps const snapshots: TeamAgentSnapshot[] = []; for (const [agentId, raw] of Object.entries(agentsRecord)) { const snapshot = snapshotFromMetadataEntry(agentId, raw); - if (snapshot !== undefined) snapshots.push(snapshot); + // Durable members live on the Session forest. Drop leftover parent-session + // team shadows so /team and the footer cannot reopen a second identity. + if (snapshot !== undefined && snapshot.kind !== 'team') snapshots.push(snapshot); } if (!snapshots.some((agent) => agent.agentId === MAIN_AGENT_ID || agent.kind === 'main')) { snapshots.unshift({ @@ -132,6 +133,207 @@ export function teamAgentsFromSessionMetadata(metadata: unknown): TeamAgentSnaps return snapshots; } +export interface SessionGraphLike { + readonly nodes: ReadonlyArray<{ + readonly id: string; + readonly title?: string; + readonly metadata?: Record | null; + }>; + readonly edges: ReadonlyArray<{ + readonly parentSessionId: string; + readonly childSessionId: string; + }>; +} + +export interface DepartmentSnapshotInput { + readonly hostTitle: string; + readonly hostSessionId?: string; + readonly metadata?: unknown; + readonly graph?: SessionGraphLike; + readonly live?: readonly TeamAgentSnapshot[]; +} + +/** + * One department snapshot for the current session: forest members, Discuss / + * independent nodes from metadata, and live report/status overlay. + * + * `/team`, `/map`, and `syncRuntimeState` must all call this so a graph refresh + * cannot drop a live Discuss round or just-updated reports. + */ +export function buildDepartmentSnapshot(input: DepartmentSnapshotInput): TeamAgentSnapshot[] { + const live = input.live ?? []; + const metadataAgents = teamAgentsFromSessionMetadata(input.metadata); + const nonTeamById = new Map(); + for (const agent of live) { + if (agent.kind === 'team') continue; + nonTeamById.set(agent.agentId, agent); + } + for (const agent of metadataAgents) { + if (agent.kind === 'team') continue; + nonTeamById.set(agent.agentId, agent); + } + const hostTitle = input.hostTitle.trim().length > 0 ? input.hostTitle : 'Main'; + const forest = + input.graph !== undefined && input.hostSessionId !== undefined + ? teamAgentsFromSessionGraph(input.hostSessionId, hostTitle, input.graph) + : []; + const forestTeam = forest.filter((agent) => agent.kind === 'team'); + const liveTeam = live.filter((agent) => agent.kind === 'team'); + const teamMembers = + input.graph !== undefined + ? overlayLiveTeamFields(forestTeam, liveTeam) + : liveTeam; + return mergeDepartmentSnapshots([...nonTeamById.values()], [ + { + agentId: MAIN_AGENT_ID, + kind: 'main', + name: hostTitle, + parentAgentId: null, + }, + ...teamMembers, + ]); +} + +/** Keep Discuss / independent transcripts from metadata; members from the forest. */ +export function mergeDepartmentSnapshots( + metadataAgents: readonly TeamAgentSnapshot[], + forestMembers: readonly TeamAgentSnapshot[], +): TeamAgentSnapshot[] { + const byId = new Map(); + for (const agent of metadataAgents) { + if (agent.kind === 'team') continue; + byId.set(agent.agentId, agent); + } + if (![...byId.values()].some((agent) => agent.kind === 'main' || agent.agentId === MAIN_AGENT_ID)) { + byId.set(MAIN_AGENT_ID, { + agentId: MAIN_AGENT_ID, + kind: 'main', + name: 'Main', + parentAgentId: null, + }); + } + for (const member of forestMembers) { + if (member.kind === 'main') { + const existing = [...byId.values()].find( + (agent) => agent.kind === 'main' || agent.agentId === MAIN_AGENT_ID, + ); + if (existing !== undefined) byId.delete(existing.agentId); + byId.set(MAIN_AGENT_ID, { + agentId: MAIN_AGENT_ID, + kind: 'main', + name: member.name, + parentAgentId: null, + role: existing?.role, + mandate: existing?.mandate, + assignedTask: existing?.assignedTask, + reportStatus: existing?.reportStatus, + reportSummary: existing?.reportSummary, + status: existing?.status, + }); + continue; + } + if (member.kind !== 'team') continue; + const sessionId = member.mountedSessionId ?? member.agentId; + if (sessionId.length === 0) continue; + byId.set(member.agentId, { + ...member, + mountedSessionId: sessionId, + }); + } + return [...byId.values()]; +} + +/** Match a department row by agent id or mounted child session id. */ +export function findTeamAgent( + agents: readonly TeamAgentSnapshot[], + id: string, +): TeamAgentSnapshot | undefined { + return agents.find((agent) => agent.agentId === id || agent.mountedSessionId === id); +} + +function overlayLiveTeamFields( + forestTeam: readonly TeamAgentSnapshot[], + liveTeam: readonly TeamAgentSnapshot[], +): TeamAgentSnapshot[] { + return forestTeam.map((member) => { + const previous = + findTeamAgent(liveTeam, member.agentId) + ?? (member.mountedSessionId === undefined + ? undefined + : findTeamAgent(liveTeam, member.mountedSessionId)); + if (previous === undefined) return member; + return { + ...member, + assignedTask: member.assignedTask ?? previous.assignedTask, + reportStatus: member.reportStatus ?? previous.reportStatus, + reportSummary: member.reportSummary ?? previous.reportSummary, + status: member.status ?? previous.status, + }; + }); +} + +/** Direct children of `hostSessionId` on a session graph. */ +export function teamAgentsFromSessionGraph( + hostSessionId: string, + hostTitle: string, + graph: SessionGraphLike, +): TeamAgentSnapshot[] { + const byId = new Map(graph.nodes.map((node) => [node.id, node])); + const children = graph.edges + .filter((edge) => edge.parentSessionId === hostSessionId) + .map((edge) => byId.get(edge.childSessionId)) + .filter((node): node is NonNullable => node !== undefined); + return teamAgentsFromMountedChildren( + hostTitle, + children.map((child) => ({ + id: child.id, + title: child.title, + name: typeof child.metadata?.['mount_name'] === 'string' ? child.metadata['mount_name'] : child.title, + role: typeof child.metadata?.['mount_role'] === 'string' ? child.metadata['mount_role'] : undefined, + mandate: typeof child.metadata?.['mount_mandate'] === 'string' ? child.metadata['mount_mandate'] : undefined, + })), + ); +} + +/** 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, + })), + ]; +} + +/** Session a `/team` row should resume. Main stays put; leftovers without a child session are skipped. */ +export function teamMemberSessionId(agent: TeamAgentSnapshot): string | undefined { + if (agent.kind === 'main') return undefined; + if (agent.mountedSessionId !== undefined && agent.mountedSessionId.length > 0) { + return agent.mountedSessionId; + } + return undefined; +} + export function flattenTeamTree(agents: readonly TeamAgentSnapshot[]): TeamTreeRow[] { const byParent = new Map(); for (const agent of agents) { @@ -237,12 +439,13 @@ export function applyAgentStatusToTeam( const team = input.team; if (team === undefined && input.agentId === MAIN_AGENT_ID) return [...agents]; - const existing = agents.find((agent) => agent.agentId === input.agentId); + const existing = findTeamAgent(agents, input.agentId); + const agentId = existing?.agentId ?? input.agentId; const next: TeamAgentSnapshot = { - agentId: input.agentId, - kind: existing?.kind ?? (input.agentId === MAIN_AGENT_ID ? 'main' : 'team'), - name: existing?.name ?? input.agentId, - parentAgentId: existing?.parentAgentId ?? (input.agentId === MAIN_AGENT_ID ? null : MAIN_AGENT_ID), + agentId, + kind: existing?.kind ?? (agentId === MAIN_AGENT_ID ? 'main' : 'team'), + name: existing?.name ?? agentId, + parentAgentId: existing?.parentAgentId ?? (agentId === MAIN_AGENT_ID ? null : MAIN_AGENT_ID), role: existing?.role, mandate: existing?.mandate, mountedSessionId: existing?.mountedSessionId, @@ -409,18 +612,44 @@ export function findAgentDiscussion( }; } -/** Sibling Chat lives on the department lead; the lead itself is not a participant. */ +/** + * Sibling Chat is stored on this session's lead (`main`). Opening a member + * switches Sessions, so the pane always reads the current session — never a + * same-session agent view. + */ +export function departmentChatOwnerId(): string { + return MAIN_AGENT_ID; +} + +/** Sibling Chat lives on the department lead; the lead itself is not a Chat sender. */ export function departmentChatLeaderId(agent: TeamAgentSnapshot): string | undefined { if (agent.kind !== 'team') return undefined; return agent.parentAgentId ?? undefined; } +export type DepartmentPaneMode = 'discuss' | 'chat'; + +export function departmentPaneEmptyHint(input: { + readonly mode: DepartmentPaneMode; + readonly hasMembers: boolean; + readonly parentSessionId?: string; +}): string { + const mounted = input.parentSessionId !== undefined && input.parentSessionId.length > 0; + if (input.mode === 'discuss') { + if (!input.hasMembers && mounted) { + return 'Discuss for this department lives on the parent session. Open it from /map.'; + } + return 'No statements in this round yet.'; + } + if (input.hasMembers) return 'No messages yet — members align here while working.'; + if (mounted) return 'Department chat lives on the parent session. Open it from /map.'; + return 'Hire partners with TeamCreate, then Chat appears here.'; +} + export function currentViewingAgentId(viewingAgentId: string | undefined): string { return viewingAgentId === undefined || viewingAgentId.length === 0 ? MAIN_AGENT_ID : viewingAgentId; } -export type DepartmentPaneMode = 'discuss' | 'chat'; - /** Discuss on → meeting track; otherwise department Chat. Closing the pane does not change this. */ export function departmentPaneMode(discussMode: boolean): DepartmentPaneMode { return discussMode ? 'discuss' : 'chat'; @@ -559,7 +788,7 @@ function applyTeamCreate( parentAgentId, role: member.role, mandate: member.mandate, - mountedSessionId: member.mountedSessionId, + mountedSessionId: member.mountedSessionId ?? member.agentId, status: 'idle', }); } @@ -589,10 +818,11 @@ function applyTeamAssign( let next = [...agents]; for (const raw of assignments) { const record = asRecord(raw); - const agentId = readString(record['agent_id']); - if (agentId === undefined) continue; + const requestedId = readString(record['agent_id']) ?? readString(record['session_id']); + if (requestedId === undefined) continue; const task = readString(record['task']); - const existing = next.find((agent) => agent.agentId === agentId); + const existing = findTeamAgent(next, requestedId); + const agentId = existing?.agentId ?? requestedId; next = upsertTeamAgent(next, { agentId, kind: existing?.kind ?? 'team', @@ -621,7 +851,10 @@ function applyTeamDismiss( const dismissed = readDismissedIds(args, output); if (dismissed.length === 0) return [...agents]; const removed = new Set(dismissed); - const remaining = agents.filter((agent) => !removed.has(agent.agentId)); + const remaining = agents.filter((agent) => { + if (removed.has(agent.agentId)) return false; + return agent.mountedSessionId === undefined || !removed.has(agent.mountedSessionId); + }); if (teamMemberCount(remaining) > 0) return remaining; return remaining.filter((agent) => agent.kind !== 'discussion'); } @@ -752,7 +985,8 @@ function readCreatedMembers( for (const raw of members) { const record = asRecord(raw); const identity = asRecord(record['identity']); - const agentId = readString(record['agentId']) ?? readString(record['agent_id']); + const sessionId = readString(record['session_id']) ?? readString(record['sessionId']); + const agentId = sessionId ?? readString(record['agentId']) ?? readString(record['agent_id']); const name = readString(identity['name']) ?? readString(record['name']); if (agentId === undefined || name === undefined) continue; created.push({ @@ -760,7 +994,7 @@ function readCreatedMembers( name, role: readString(identity['role']) ?? readString(record['role']), mandate: readString(identity['mandate']) ?? readString(record['mandate']), - mountedSessionId: readString(record['session_id']), + mountedSessionId: sessionId ?? agentId, }); } return created; diff --git a/apps/nori-code/test/tui/components/chrome/footer.test.ts b/apps/nori-code/test/tui/components/chrome/footer.test.ts index 6c163f3c..861110c1 100644 --- a/apps/nori-code/test/tui/components/chrome/footer.test.ts +++ b/apps/nori-code/test/tui/components/chrome/footer.test.ts @@ -204,13 +204,13 @@ describe('FooterComponent displayName override', () => { expect(out).toContain('[team 1]'); }); - it('shows the viewed partner name in the footer', () => { + it('shows the mounted member title in the footer', () => { const footer = new FooterComponent({ ...appState, - viewingAgentId: 'reviewer', + sessionTitle: 'Reviewer', + parentSessionId: 'sess_parent', teamAgents: [ - { agentId: 'main', kind: 'main', name: 'Main', parentAgentId: null }, - { agentId: 'reviewer', kind: 'team', name: 'Reviewer', parentAgentId: 'main' }, + { agentId: 'main', kind: 'main', name: 'Reviewer', parentAgentId: null }, ], }); const out = footer.render(160).join('\n').replaceAll(/\u001B\[[0-9;]*m/g, ''); diff --git a/apps/nori-code/test/tui/controllers/team-view.test.ts b/apps/nori-code/test/tui/controllers/team-view.test.ts index 219e7676..b40181bd 100644 --- a/apps/nori-code/test/tui/controllers/team-view.test.ts +++ b/apps/nori-code/test/tui/controllers/team-view.test.ts @@ -7,10 +7,11 @@ import type { TeamAgentSnapshot } from '#/tui/utils/team-tree'; import type { Event } from '@nori-code/sdk'; const reviewer: TeamAgentSnapshot = { - agentId: 'reviewer', + agentId: 'sess_reviewer', kind: 'team', name: 'Reviewer', parentAgentId: 'main', + mountedSessionId: 'sess_reviewer', }; const discussion: TeamAgentSnapshot = { @@ -63,31 +64,43 @@ function createController(overrides: Partial = {}) { initialAppState: fakeAppState(overrides), startup: { continueLast: false, permission: undefined, discuss: false }, }); - const hydrateFromReplay = vi.fn(async () => true); - const prepareTranscriptForAgentView = vi.fn(); const showStatus = vi.fn(); const setAppState = vi.fn((patch: Partial) => { Object.assign(state.appState, patch); }); const session = { - getResumeState: vi.fn(() => ({ sessionMetadata: {}, agents: {} })), + getResumeState: vi.fn(() => ({ + sessionMetadata: { + agents: { + main: { + chat: { + messages: [ + { + messageId: 1, + agentId: 'sess_reviewer', + name: 'Reviewer', + message: 'Taking the footer.', + }, + ], + }, + }, + }, + }, + })), }; const host = { state, session, harness: { withInteractiveAgent: (_id: string, fn: () => unknown) => fn() }, - sessionReplay: { hydrateFromReplay }, setAppState, showStatus, showError: vi.fn(), restoreEditor: vi.fn(), - prepareTranscriptForAgentView, }; return { controller: new TeamViewController(host as unknown as TeamViewHost), state, - hydrateFromReplay, - prepareTranscriptForAgentView, + session, showStatus, setAppState, }; @@ -101,23 +114,23 @@ function paneText(state: ReturnType): string { } describe('TeamViewController', () => { - it('opens a member session and hydrates that agent, not main', async () => { - const { controller, state, hydrateFromReplay, prepareTranscriptForAgentView } = createController(); - await controller.open(reviewer); - expect(state.appState.viewingAgentId).toBe('reviewer'); - expect(prepareTranscriptForAgentView).toHaveBeenCalledWith('reviewer'); - expect(hydrateFromReplay).toHaveBeenCalledWith(expect.anything(), 'reviewer'); - expect(controller.isPaneVisible()).toBe(true); + it('loads department Chat from the current session lead, not a member view', () => { + const { controller, state, session } = createController(); + controller.reveal(); + controller.seedFromSession(session as never); + expect(state.appState.viewingAgentId).toBe('main'); expect(paneText(state)).toContain('Chat'); + expect(paneText(state)).toContain('Taking the footer.'); }); - it('opens a discussion node by revealing the pane without switching agents', async () => { - const { controller, state, hydrateFromReplay, prepareTranscriptForAgentView } = createController(); - await controller.open(discussion); - expect(state.appState.viewingAgentId).toBe('main'); - expect(prepareTranscriptForAgentView).not.toHaveBeenCalled(); - expect(hydrateFromReplay).not.toHaveBeenCalled(); - expect(controller.isPaneVisible()).toBe(true); + it('tells a mounted child that Chat lives on the parent session', () => { + const { controller, state } = createController({ + teamAgents: [{ agentId: 'main', kind: 'main', name: 'Reviewer', parentAgentId: null }], + parentSessionId: 'sess_parent', + sessionTitle: 'Reviewer', + }); + controller.reveal(); + expect(paneText(state)).toContain('parent session'); }); it('forces the Discuss meeting track while Discuss is on, and hide only closes the pane', () => { @@ -151,7 +164,7 @@ describe('TeamViewController', () => { controller.reveal(); controller.routeEvent({ type: 'tool.call.started', - agentId: 'reviewer', + agentId: 'sess_reviewer', sessionId: 'sess-1', turnId: 1, toolCallId: 'ts-1', diff --git a/apps/nori-code/test/tui/kimi-tui-message-flow.test.ts b/apps/nori-code/test/tui/kimi-tui-message-flow.test.ts index e246b195..94ec8779 100644 --- a/apps/nori-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/nori-code/test/tui/kimi-tui-message-flow.test.ts @@ -90,7 +90,6 @@ interface MessageDriver { getCurrentSessionId(): string; toggleToolOutputExpansion(): void; teamViewController: { - switchTo(agentId: string, name: string): Promise; hide(): boolean; reveal(): boolean; isPaneVisible(): boolean; @@ -2247,43 +2246,30 @@ command = "vim" expect(recollapsedLines.length).toBeGreaterThan(0); }); - it('paints a partner tool into the main view after opening that member', async () => { + it('keeps the lead transcript on main while department members exist', async () => { const { driver } = await makeDriver(); driver.state.appState.teamAgents = [ { agentId: 'main', kind: 'main', name: 'Main', parentAgentId: null }, - { agentId: 'reviewer', kind: 'team', name: 'Reviewer', parentAgentId: 'main' }, + { agentId: 'sess_reviewer', kind: 'team', name: 'Reviewer', parentAgentId: 'main', mountedSessionId: 'sess_reviewer' }, ]; - await driver.teamViewController.switchTo('reviewer', 'Reviewer'); - expect(driver.teamViewController.viewingAgentId()).toBe('reviewer'); + expect(driver.teamViewController.viewingAgentId()).toBe('main'); driver.sessionEventHandler.handleEvent( { - type: 'assistant.delta', - agentId: 'reviewer', + type: 'tool.call.started', + agentId: 'sess_reviewer', sessionId: 'ses-1', turnId: 2, - delta: 'Reviewer session text', + toolCallId: 'tc-1', + name: 'Read', + args: { path: 'README.md' }, } as Event, vi.fn(), ); driver.streamingUI.flushNow(); - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Reviewer session text'); - - driver.sessionEventHandler.handleEvent( - { - type: 'assistant.delta', - agentId: 'main', - sessionId: 'ses-1', - turnId: 1, - delta: 'lead should stay off this member view', - } as Event, - vi.fn(), - ); - driver.streamingUI.flushNow(); - expect(stripSgr(renderTranscript(driver))).not.toContain('lead should stay off this member view'); + expect(stripSgr(renderTranscript(driver))).not.toContain('README.md'); }); it('does not mix /btw answers into Discuss utterance blocks', async () => { 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..9eb0db1b 100644 --- a/apps/nori-code/test/tui/utils/team-tree.test.ts +++ b/apps/nori-code/test/tui/utils/team-tree.test.ts @@ -10,12 +10,18 @@ import { formatTeamReportsStatus, formatTeamRowSecondary, shouldPaintDiscussUtterance, + teamAgentsFromMountedChildren, + teamMemberSessionId, + teamAgentsFromSessionGraph, teamAgentsFromSessionMetadata, + mergeDepartmentSnapshots, + buildDepartmentSnapshot, teamChatMessagesFromMetadata, teamHasBlockingReports, teamMemberCount, teamSpeakingLabel, departmentChatLeaderId, + departmentPaneEmptyHint, departmentPaneMode, type TeamAgentSnapshot, } from '#/tui/utils/team-tree'; @@ -27,13 +33,52 @@ 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', + }, + ]); + }); + + it('opens a forest member through its child session id', () => { + const [lead, reviewer] = teamAgentsFromMountedChildren('Lead', [ + { id: 'sess_reviewer', title: 'Reviewer' }, + ]); + expect(teamMemberSessionId(lead!)).toBeUndefined(); + expect(teamMemberSessionId(reviewer!)).toBe('sess_reviewer'); + expect(teamMemberSessionId({ + agentId: 'agent_ghost', + kind: 'team', + name: 'Ghost', + parentAgentId: 'main', + })).toBeUndefined(); + expect(teamMemberSessionId({ + agentId: 'agent_ghost', + kind: 'discussion', + name: 'Round', + parentAgentId: 'main', + })).toBeUndefined(); + }); +}); + describe('teamAgentsFromSessionMetadata', () => { it('returns an empty list when resume metadata has no agents', () => { expect(teamAgentsFromSessionMetadata(undefined)).toEqual([]); expect(teamAgentsFromSessionMetadata({})).toEqual([]); }); - it('rebuilds the department tree from resume metadata', () => { + it('keeps Discuss nodes from resume metadata and drops leftover team shadows', () => { const agents = teamAgentsFromSessionMetadata({ agents: { main: { type: 'main', name: 'Main', parentAgentId: null }, @@ -43,20 +88,143 @@ describe('teamAgentsFromSessionMetadata', () => { parentAgentId: 'main', role: 'code review', mountedSessionId: 'session-reviewer', - assignedTask: 'Review the TUI footer', - teamReport: { status: 'blocked', summary: 'Need a decision' }, + }, + 'discuss-1': { + type: 'sub', + name: 'Align', + parentAgentId: 'main', + discussion: { + status: 'active', + currentTurnAgentId: 'sess_reviewer', + participantAgentIds: ['sess_reviewer'], + }, }, }, }); - expect(teamMemberCount(agents)).toBe(1); - const reviewer = agents.find((agent) => agent.agentId === 'reviewer'); - expect(reviewer?.kind).toBe('team'); - expect(reviewer?.role).toBe('code review'); - expect(reviewer?.mountedSessionId).toBe('session-reviewer'); - expect(reviewer?.reportStatus).toBe('blocked'); - expect(flattenTeamTree(agents).map((row) => `${row.depth}:${row.agent.name}`)).toEqual([ - '0:Main', - '1:Reviewer', + expect(teamMemberCount(agents)).toBe(0); + expect(agents.find((agent) => agent.agentId === 'reviewer')).toBeUndefined(); + expect(agents.find((agent) => agent.kind === 'discussion')).toMatchObject({ + agentId: 'discuss-1', + discussionTurnAgentId: 'sess_reviewer', + }); + }); +}); + +describe('mergeDepartmentSnapshots', () => { + it('keeps forest members when metadata only has Discuss', () => { + const forest = teamAgentsFromMountedChildren('Lead', [ + { id: 'sess_reviewer', title: 'Reviewer', role: 'reviewer' }, + ]); + const merged = mergeDepartmentSnapshots( + teamAgentsFromSessionMetadata({ + agents: { + main: { type: 'main', name: 'Lead', parentAgentId: null }, + 'discuss-1': { + type: 'sub', + discussion: { status: 'active', participantAgentIds: ['sess_reviewer'] }, + }, + }, + }), + forest, + ); + expect(merged.map((agent) => agent.agentId).sort()).toEqual(['discuss-1', 'main', 'sess_reviewer']); + expect(teamMemberSessionId(merged.find((agent) => agent.agentId === 'sess_reviewer')!)).toBe('sess_reviewer'); + expect(merged.find((agent) => agent.kind === 'main')?.name).toBe('Lead'); + }); +}); + +describe('buildDepartmentSnapshot', () => { + const graph = { + nodes: [ + { id: 'sess_parent', title: 'Lead' }, + { id: 'sess_reviewer', title: 'Reviewer', metadata: { mount_name: 'Reviewer', mount_role: 'reviewer' } }, + ], + edges: [{ parentSessionId: 'sess_parent', childSessionId: 'sess_reviewer' }], + }; + + it('keeps a live Discuss node when a graph refresh only has members', () => { + const live: TeamAgentSnapshot[] = [ + { agentId: 'main', kind: 'main', name: 'Lead', parentAgentId: null }, + { + agentId: 'sess_reviewer', + kind: 'team', + name: 'Reviewer', + parentAgentId: 'main', + mountedSessionId: 'sess_reviewer', + reportStatus: 'blocked', + status: 'running', + }, + { + agentId: 'discuss-1', + kind: 'discussion', + name: 'Align', + parentAgentId: 'main', + discussionTurnAgentId: 'sess_reviewer', + }, + ]; + const next = buildDepartmentSnapshot({ + hostSessionId: 'sess_parent', + hostTitle: 'Lead', + graph, + live, + }); + expect(next.find((agent) => agent.kind === 'discussion')?.agentId).toBe('discuss-1'); + expect(next.find((agent) => agent.agentId === 'sess_reviewer')?.reportStatus).toBe('blocked'); + expect(next.find((agent) => agent.agentId === 'sess_reviewer')?.status).toBe('running'); + }); + + it('keeps a live Discuss node alongside a newer metadata round', () => { + const next = buildDepartmentSnapshot({ + hostSessionId: 'sess_parent', + hostTitle: 'Lead', + graph, + metadata: { + agents: { + main: { type: 'main', name: 'Lead', parentAgentId: null }, + 'discuss-2': { + type: 'sub', + discussion: { status: 'active', currentTurnAgentId: 'sess_reviewer' }, + }, + }, + }, + live: [ + { + agentId: 'discuss-1', + kind: 'discussion', + name: 'Old', + parentAgentId: 'main', + }, + ], + }); + expect(next.filter((agent) => agent.kind === 'discussion').map((agent) => agent.agentId)).toEqual([ + 'discuss-1', + 'discuss-2', + ]); + }); +}); + +describe('teamAgentsFromSessionGraph', () => { + it('reads direct children of the current session', () => { + expect(teamAgentsFromSessionGraph('sess_parent', 'Lead', { + nodes: [ + { id: 'sess_parent', title: 'Lead' }, + { id: 'sess_reviewer', title: 'Fallback', metadata: { mount_name: 'Reviewer', mount_role: 'reviewer' } }, + { id: 'sess_other', title: 'Other' }, + ], + edges: [ + { parentSessionId: 'sess_parent', childSessionId: 'sess_reviewer' }, + { parentSessionId: 'sess_other', childSessionId: 'sess_nested' }, + ], + })).toEqual([ + { agentId: 'main', kind: 'main', name: 'Lead', parentAgentId: null }, + { + agentId: 'sess_reviewer', + kind: 'team', + name: 'Reviewer', + parentAgentId: 'main', + role: 'reviewer', + mountedSessionId: 'sess_reviewer', + }, ]); }); }); @@ -80,8 +248,8 @@ describe('applyTeamToolResultToTeam', () => { ); expect(next.some((agent) => agent.kind === 'main')).toBe(true); expect(teamMemberCount(next)).toBe(1); - expect(next.find((agent) => agent.agentId === 'reviewer')?.mandate).toBe('Keep diffs small'); - expect(next.find((agent) => agent.agentId === 'reviewer')?.mountedSessionId).toBe('session-reviewer'); + expect(next.find((agent) => agent.agentId === 'session-reviewer')?.mandate).toBe('Keep diffs small'); + expect(next.find((agent) => agent.agentId === 'session-reviewer')?.mountedSessionId).toBe('session-reviewer'); }); it('records assignments and dismissals', () => { @@ -309,6 +477,16 @@ describe('department pane helpers', () => { ).toBe('main'); }); + it('tells a mounted child that Chat lives on the parent session', () => { + expect(departmentPaneEmptyHint({ + mode: 'chat', + hasMembers: false, + parentSessionId: 'sess_parent', + })).toContain('parent session'); + expect(departmentPaneEmptyHint({ mode: 'chat', hasMembers: true })).toContain('No messages yet'); + expect(departmentPaneEmptyHint({ mode: 'chat', hasMembers: false })).toContain('TeamCreate'); + }); + it('reads department chat history from resume metadata', () => { const messages = teamChatMessagesFromMetadata( { diff --git a/apps/nori-web/src/App.tsx b/apps/nori-web/src/App.tsx index 6f990697..8c4c2444 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'; @@ -121,10 +121,8 @@ export function App() { useGlobalErrors(); const [activeView, setActiveView] = useState('chat'); const [activeAgentSelection, setActiveAgentSelection] = useState<{ sessionId: string; agent: SessionAgent } | null>(null); - // Keep the known agent object while the host session's agent list is still - // loading so map/team opens do not briefly bind chat to main. - const [pendingAgentOpen, setPendingAgentOpen] = useState<{ sessionId: string; agent: SessionAgent } | null>(null); const [sessionAgents, setSessionAgents] = useState([]); + const [leaderDiscussionAgents, setLeaderDiscussionAgents] = useState([]); const [sidebarExpanded, setSidebarExpanded] = useState(loadSidebarExpanded); const [sidebarWidth, setSidebarWidth] = useState(() => Math.max(220, Math.min(480, Number(localStorage.getItem('nori-sidebar-width')) || 256))); const [sidebarTab, setSidebarTab] = useState('sessions'); @@ -197,35 +195,26 @@ export function App() { refresh: refreshSessions, } = useSessions(); const activeSession: Session | null = sessions.find(session => session.id === sessionId) ?? null; - const pendingAgent = pendingAgentOpen?.sessionId === sessionId ? pendingAgentOpen.agent : null; - const activeAgent = ( - activeAgentSelection?.sessionId === sessionId ? activeAgentSelection.agent : null - ) ?? pendingAgent; + const activeAgent = activeAgentSelection?.sessionId === sessionId ? activeAgentSelection.agent : null; const activeAgentId = activeAgent?.agent_id ?? 'main'; const selectSessionAgent = useCallback((agent: SessionAgent | null) => { + const mountedId = agent?.mounted_session_id + ?? (agent?.kind === 'team' ? agent.agent_id : undefined); + if (agent !== null && mountedId !== undefined && mountedId !== sessionId + && sessions.some(session => session.id === mountedId)) { + setActiveAgentSelection(null); + switchSession(mountedId); + setActiveView('chat'); + return; + } setActiveAgentSelection(agent && agent.agent_id !== 'main' && agent.kind !== 'main' && sessionId ? { sessionId, agent } : null); setActiveView('chat'); - }, [sessionId]); + }, [sessionId, sessions, switchSession]); useEffect(() => { - setPendingAgentOpen(current => ( - current !== null && current.sessionId !== sessionId ? null : current - )); setActiveAgentSelection(null); setSessionAgents([]); + setLeaderDiscussionAgents([]); }, [sessionId]); - useEffect(() => { - if (pendingAgentOpen === null || pendingAgentOpen.sessionId !== sessionId) return; - const agent = sessionAgents.find(candidate => candidate.agent_id === pendingAgentOpen.agent.agent_id); - if (agent === undefined) { - if (sessionAgents.length === 0) return; - // Host agents loaded, but the requested member is gone (dismissed / - // detached). Drop the pending open instead of leaving chat on a dead id. - setPendingAgentOpen(null); - return; - } - setActiveAgentSelection({ sessionId: pendingAgentOpen.sessionId, agent }); - setPendingAgentOpen(null); - }, [pendingAgentOpen, sessionAgents, sessionId]); useEffect(() => { if (activeAgentSelection === null || activeAgentSelection.sessionId !== sessionId) return; const current = sessionAgents.find(agent => agent.agent_id === activeAgentSelection.agent.agent_id); @@ -313,18 +302,11 @@ export function App() { const load = async () => { try { const result = await api.sessions.getAgents(sessionId); - const nextAgents = result.items ?? []; + const nextAgents = (result.items ?? []).filter(agent => agent.kind !== 'team'); // 每 4 秒一轮,返回内容通常与上一轮完全相同。复用上一次的数组身份, // 让依赖 sessionAgents 的下游 effect(部门轨的讨论拉取)不会被轮询本身唤醒。 if (!disposed) { setSessionAgents(previous => sameAgentList(previous, nextAgents) ? previous : nextAgents); - setPendingAgentOpen(current => ( - current !== null - && current.sessionId === sessionId - && !nextAgents.some(agent => agent.agent_id === current.agent.agent_id) - ? null - : current - )); } } catch { // 轮询失败保持上一次列表,下一轮重试。 @@ -334,13 +316,52 @@ export function App() { const timer = window.setInterval(() => { void load(); }, 4_000); return () => { disposed = true; window.clearInterval(timer); }; }, [sessionId, agentTreeRevision]); + const departmentLeaderSessionId = activeSession === null + ? null + : parentSessionIdOf(activeSession) ?? activeSession.id; + useEffect(() => { + if (departmentLeaderSessionId === null || departmentLeaderSessionId === sessionId) { + setLeaderDiscussionAgents([]); + return; + } + let disposed = false; + const load = async () => { + try { + const result = await api.sessions.getAgents(departmentLeaderSessionId); + const nextAgents = (result.items ?? []).filter(agent => agent.kind === 'discussion'); + if (!disposed) { + setLeaderDiscussionAgents(previous => sameAgentList(previous, nextAgents) ? previous : nextAgents); + } + } catch { + // Keep the last discussion list; the next poll retries. + } + }; + void load(); + const timer = window.setInterval(() => { void load(); }, 4_000); + return () => { disposed = true; window.clearInterval(timer); }; + }, [departmentLeaderSessionId, sessionId, agentTreeRevision]); const browserPermissions = useBrowserPermissions(); + const departmentAgents = useMemo( + () => departmentAgentsFromSessions(sessions, activeSession), + [sessions, activeSession], + ); + const displayAgents = useMemo( + () => mergeSessionAgents( + bindTreeToSession(sessionAgents, sessionId), + departmentAgents, + bindTreeToSession(leaderDiscussionAgents, departmentLeaderSessionId), + ), + [departmentAgents, departmentLeaderSessionId, leaderDiscussionAgents, sessionAgents, sessionId], + ); const sessionActiveAgentCount = countActiveAgents(activity, sessionId ?? undefined); - const sessionTreeTokens = sessionAgents.reduce((total, agent) => total + (agent.tokens ?? 0), 0); - // 与当前 agent 相关的 Discuss 轮次:它自己主持的,或它作为成员参加的。 + const sessionTreeTokens = displayAgents.reduce((total, agent) => total + (agent.tokens ?? 0), 0); + // 与当前 Session 相关的 Discuss 轮次:它自己主持的,或它作为成员参加的。 // 轮次是树里独立的节点,所以不能从被查看 agent 自己的节点上读当前发言人; // WS 事件给出的 discussionTurnAgentId 比轮询的树新,优先采用。 - const activeDiscussion = findAgentDiscussion(sessionAgents, activeAgentId, discussionTurnAgentId); + const discussionViewerId = activeAgentId !== 'main' && activeAgent?.kind !== 'team' + ? activeAgentId + : (activeSession?.id ?? activeAgentId); + const activeDiscussion = findAgentDiscussion(displayAgents, discussionViewerId, discussionTurnAgentId); const effectiveGlobalActiveAgentCount = countActiveAgents(activity); const sessionTitles = Object.fromEntries(sessions.map(session => [session.id, session.title || session.id])); @@ -564,10 +585,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 +641,7 @@ export function App() { streamingTurnId={activeTurnId} activeAgentCount={sessionActiveAgentCount} activeAgentTokens={sessionTreeTokens} - sessionAgents={sessionAgents} + sessionAgents={displayAgents} departmentChat={departmentChat} discussion={activeDiscussion} departmentRevision={agentTreeRevision} @@ -646,19 +669,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 + ?? departmentAgents.find(agent => agent.agent_id === sourceAgentId)?.mounted_session_id; + setActiveAgentSelection(null); + switchSession(mountedId ?? sourceSessionId); setActiveView('chat'); closeSidebarOnNarrowViewport(); }} @@ -749,12 +764,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 +966,123 @@ 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; +} + +export function departmentAgentsFromSessions( + sessions: readonly Session[], + active: Session | null, +): SessionAgent[] { + if (active === null) return []; + const parentId = parentSessionIdOf(active); + const byId = new Map(); + const addMember = (session: Session, leaderId: string) => { + byId.set(session.id, { + agent_id: session.id, + kind: 'team', + parent_agent_id: leaderId, + name: sessionStringMeta(session, 'mount_name') || session.title || session.id, + role: sessionStringMeta(session, 'mount_role'), + mandate: sessionStringMeta(session, 'mount_mandate'), + assigned_task: sessionStringMeta(session, 'department_assigned_task'), + status: session.status, + last_active: session.updated_at, + mounted_session_id: session.id, + }); + }; + for (const session of sessions) { + if (parentSessionIdOf(session) === active.id) addMember(session, active.id); + } + if (parentId !== undefined) { + for (const session of sessions) { + if (parentSessionIdOf(session) === parentId) addMember(session, parentId); + } + } + return [...byId.values()]; +} + +function sessionStringMeta(session: Session, key: string): string | undefined { + const value = session.metadata?.[key]; + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +function bindTreeToSession( + agents: readonly SessionAgent[], + sessionId: string | null | undefined, +): SessionAgent[] { + if (sessionId === null || sessionId === undefined || sessionId.length === 0) return [...agents]; + return agents.map(agent => ( + agent.parent_agent_id === 'main' ? { ...agent, parent_agent_id: sessionId } : agent + )); +} + +function mergeSessionAgents( + agents: readonly SessionAgent[], + department: readonly SessionAgent[], + extraDiscussions: readonly SessionAgent[] = [], +): SessionAgent[] { + const byId = new Map(); + for (const agent of agents) { + if (agent.kind === 'team') continue; + byId.set(agent.agent_id, agent); + } + for (const agent of extraDiscussions) { + if (agent.kind !== 'discussion') continue; + 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 : { ...existing, ...agent }); + } + 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 +1093,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..559fbc98 100644 --- a/apps/nori-web/src/api/client.ts +++ b/apps/nori-web/src/api/client.ts @@ -155,7 +155,7 @@ export interface SessionAgent { discussion_turn_agent_id?: string; /** Members taking part in this Discuss round; only discussion nodes carry it. */ discussion_participant_agent_ids?: readonly string[]; - /** Dual-write child session for TeamCreate / map mount members. */ + /** Child session that owns this durable member. */ mounted_session_id?: string; } @@ -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/DepartmentPanel.tsx b/apps/nori-web/src/components/DepartmentPanel.tsx index dea618ea..9f258d46 100644 --- a/apps/nori-web/src/components/DepartmentPanel.tsx +++ b/apps/nori-web/src/components/DepartmentPanel.tsx @@ -10,6 +10,20 @@ import { sessionAgentDisplayName } from '../utils/session-agent'; * 人类只读——这是成员之间的通道。自己这方的气泡用主题色区分。 */ +function meetingParticipantNames( + round: SessionAgent | undefined, + sessionAgents: readonly SessionAgent[], + nameOf: (agentId: string) => string, +): string[] { + const invited = round?.discussion_participant_agent_ids ?? []; + const resolved = invited.filter(id => sessionAgents.some(agent => agent.agent_id === id)); + if (resolved.length > 0) return resolved.map(nameOf); + const leaderId = round?.parent_agent_id; + return sessionAgents + .filter(agent => agent.kind === 'team' && agent.parent_agent_id === leaderId && agent.archived !== true) + .map(agent => sessionAgentDisplayName(agent)); +} + function messageTimeOf(message: ChatMessage): number { const parsed = Date.parse(message.createdAt ?? ''); return Number.isFinite(parsed) ? parsed : 0; @@ -137,12 +151,12 @@ export function DepartmentMeetingPanel({ sessionId, discussionAgentId, selfAgent const agent = sessionAgents.find(candidate => candidate.agent_id === agentId); return agent === undefined ? agentId : sessionAgentDisplayName(agent); }; - // 轮次节点自己带着议题(summary)、主持人(父级)和参会名单。 + // 轮次节点自己带着议题(summary)、主持人(父级)。参会名单只认还活在部门森林里的 Session。 const round = sessionAgents.find(candidate => candidate.agent_id === discussionAgentId); const header = ; const empty = (note: string) => (
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 &&