Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/components/BottomPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export const BottomPanel = memo(function BottomPanel({ directory, serverId }: Bo
return serverStore.onServerChange(() => {
void restoreSessions(++restoreRequestIdRef.current)
})
}, [normalizedDirectory])
}, [normalizedDirectory, serverId])

// 创建新终端
const handleNewTerminal = useCallback(async () => {
Expand All @@ -120,7 +120,7 @@ export const BottomPanel = memo(function BottomPanel({ directory, serverId }: Bo
} catch (error) {
uiErrorHandler('create terminal', error)
}
}, [normalizedDirectory])
}, [normalizedDirectory, serverId])

// 关闭终端
const handleCloseTerminal = useCallback(
Expand All @@ -131,7 +131,7 @@ export const BottomPanel = memo(function BottomPanel({ directory, serverId }: Bo
// ignore - may already be closed
}
},
[normalizedDirectory],
[normalizedDirectory, serverId],
)

// 渲染内容
Expand Down Expand Up @@ -220,7 +220,7 @@ export const BottomPanel = memo(function BottomPanel({ directory, serverId }: Bo
</>
)
},
[isRestoring, handleNewTerminal, directory, sessionId, isPanelResizing, t],
[isRestoring, handleNewTerminal, directory, sessionId, isPanelResizing, t, serverId],
)

return (
Expand Down
2 changes: 1 addition & 1 deletion src/components/RightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ export const RightPanel = memo(function RightPanel({
</>
)
},
[normalizedDirectory, sessionId, isPanelResizing, t],
[normalizedDirectory, sessionId, isPanelResizing, t, serverId],
)

if (inline) {
Expand Down
6 changes: 3 additions & 3 deletions src/components/SessionChangesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ export const SessionChangesPanel = memo(function SessionChangesPanel({
setProjectLoading(false)
}
}
}, [directory, sessionId, t])
}, [directory, sessionId, t, serverId])

const loadDiffMode = useCallback(
async (mode: ChangeMode, options?: { force?: boolean; project?: ApiProject | null }) => {
Expand Down Expand Up @@ -396,7 +396,7 @@ export const SessionChangesPanel = memo(function SessionChangesPanel({
}
}
},
[directory, loadedModes, project, sessionId, t],
[directory, loadedModes, project, sessionId, t, serverId],
)

useEffect(() => {
Expand Down Expand Up @@ -483,7 +483,7 @@ export const SessionChangesPanel = memo(function SessionChangesPanel({
} finally {
setInitializingGit(false)
}
}, [directory, loadProjectState, t])
}, [directory, loadProjectState, t, serverId])

// 选中文件
const handleSelectFile = useCallback((file: string) => {
Expand Down
8 changes: 1 addition & 7 deletions src/components/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -812,13 +812,7 @@ export const Terminal = memo(function Terminal({ ptyId, directory, serverId, isA
terminalRef.current = null
fitAddonRef.current = null
}
}, [
ptyId,
hasBeenActive,
clearStickyModifiers,
sendTerminalData,
preferTouchUi,
])
}, [ptyId, hasBeenActive, clearStickyModifiers, sendTerminalData, preferTouchUi, serverId])

useEffect(() => {
const container = containerRef.current
Expand Down
3 changes: 3 additions & 0 deletions src/features/chat/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,8 @@ export const ChatPane = memo(function ChatPane({

const inlineToolRequestCtx = useMemo<InlineToolRequestContextValue>(
() => ({
// 子 session 请求匹配必须用 pane 绑定的服务器,而不是全局活动服务器(多服务器 / WSL 下两者不同)
serverId: paneServerId,
pendingPermissions: pendingPermissionRequests,
pendingQuestions: pendingQuestionRequests,
onPermissionReply: (requestId, reply) => {
Expand All @@ -803,6 +805,7 @@ export const ChatPane = memo(function ChatPane({
isReplying,
}),
[
paneServerId,
pendingPermissionRequests,
pendingQuestionRequests,
handlePermissionReply,
Expand Down
88 changes: 88 additions & 0 deletions src/features/chat/InlineToolRequestContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* InlineToolRequestContext 契约测试
*
* 核心契约:task 工具匹配子 session 的内嵌请求时,复合 key 必须用「pane 绑定的服务器」合成。
* 工具 metadata 里的 sessionId 是原始 id,若用 splitSessionKey 猜服务器会回退到全局活动服务器,
* 而 childSessionStore 按真实服务器注册子 session —— 多服务器 / WSL 下孙 session 的请求
* 永远关联不到 task 工具,内嵌权限 / 提问 UI 不出现。
*/

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { findPermissionRequestForTool, findQuestionRequestForTool } from './InlineToolRequestContext'
import type { ApiPermissionRequest, ApiQuestionRequest } from '../../api'

const { childSessionStoreMock } = vi.hoisted(() => {
// 仿真 childSessionStore 的存储语义:key 一律是复合 `${serverId}::${sessionId}`,按父子链递归查找
const parentByKey = new Map<string, string>()
const isChildOf = (sessionId: string, parentId: string): boolean => {
const parent = parentByKey.get(sessionId)
if (!parent) return false
return parent === parentId || isChildOf(parent, parentId)
}
return {
childSessionStoreMock: {
register(childKey: string, parentKey: string) {
parentByKey.set(childKey, parentKey)
},
reset() {
parentByKey.clear()
},
isChildOf,
},
}
})

vi.mock('../../store', () => ({
childSessionStore: childSessionStoreMock,
}))

// 全局活动服务器固定为 local:与 pane 绑定的 wsl:Ubuntu 形成错位,逼出「猜服务器」的错误路径
vi.mock('../../store/serverStore', () => ({
serverStore: {
getActiveServerId: () => 'local',
},
}))

describe('task 工具按 pane 服务器匹配子 session 请求', () => {
const PANE_SERVER = 'wsl:Ubuntu'

beforeEach(() => {
childSessionStoreMock.reset()
// task 的直接子 session 与孙 session 都注册在 pane 真实所属服务器下
childSessionStoreMock.register(`${PANE_SERVER}::ses_child`, `${PANE_SERVER}::ses_parent`)
childSessionStoreMock.register(`${PANE_SERVER}::ses_grand`, `${PANE_SERVER}::ses_child`)
})

it('孙 session 发出的权限请求能匹配到 task 工具(全局活动服务器是 local,pane 绑定 wsl:Ubuntu)', () => {
const permission: ApiPermissionRequest = {
id: 'perm-1',
sessionID: `${PANE_SERVER}::ses_grand`,
permission: 'bash',
patterns: ['npm test'],
metadata: {},
always: [],
}

const matched = findPermissionRequestForTool([permission], 'call-unknown', {
sessionKey: 'ses_child',
serverId: PANE_SERVER,
})

expect(matched).toBe(permission)
})

it('孙 session 发出的提问请求同样按 pane 服务器匹配', () => {
const question: ApiQuestionRequest = {
id: 'ques-1',
sessionID: `${PANE_SERVER}::ses_grand`,
questions: [],
}

const matched = findQuestionRequestForTool([question], 'call-unknown', {
sessionKey: 'ses_child',
serverId: PANE_SERVER,
})

expect(matched).toBe(question)
})
})
44 changes: 32 additions & 12 deletions src/features/chat/InlineToolRequestContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,19 @@ import type { ApiPermissionRequest, ApiQuestionRequest, PermissionReply, Questio
import { childSessionStore } from '../../store'
import { makeSessionKey, splitSessionKey } from '../../utils/sessionKey'

/**
* task 工具匹配子 session 请求时的定位信息。
* sessionKey 取自工具 metadata,可能是原始 id;serverId 是 pane 绑定的服务器(权威值),
* 绝不能从「全局活动服务器」猜测——多服务器 / WSL 场景下两者不同,孙 session 会永远匹配不上。
*/
export interface TaskChildSessionRef {
sessionKey: string
serverId: string
}

export interface InlineToolRequestContextValue {
/** 当前 pane 绑定的服务器,供 task 工具解析子 session 的服务器作用域 key */
serverId: string
/** 当前 pending 的权限请求 */
pendingPermissions: ApiPermissionRequest[]
/** 当前 pending 的提问请求 */
Expand All @@ -27,6 +39,8 @@ export interface InlineToolRequestContextValue {
}

const defaultValue: InlineToolRequestContextValue = {
// 没有 Provider 就没有 pane 绑定,空串表示「无权威服务器」,不做任何猜测
serverId: '',
pendingPermissions: [],
pendingQuestions: [],
onPermissionReply: () => {},
Expand All @@ -43,26 +57,30 @@ export function useInlineToolRequests() {

/**
* 根据 callID 查找关联的权限请求。
* 对于 task tool,额外传入 childSessionId
* 对于 task tool,额外传入 child(子 session key + pane 绑定的权威服务器)
* 匹配子 session(及其子孙)内部发出的权限请求。
*/
export function findPermissionRequestForTool(
pendingPermissions: ApiPermissionRequest[],
callID: string,
childSessionId?: string,
child?: TaskChildSessionRef,
): ApiPermissionRequest | undefined {
// 先按 callID 精确匹配(直接工具调用)
const direct = pendingPermissions.find(p => p.tool?.callID === callID)
if (direct) return direct

// 对 task tool,按子 session 归属匹配
if (childSessionId) {
// 消息 metadata 里的 sessionId 是原始 id,pending 请求的 sessionID 可能是复合 key(SSE)
// 或原始 id(轮询):统一按原始 id 比较,isChildOf 需要复合 key(childSessionStore 存复合)
const { serverId: childServerId, sessionId: childRawId } = splitSessionKey(childSessionId)
const childScoped = childSessionId.includes('::') ? childSessionId : makeSessionKey(childServerId, childRawId)
if (child) {
// 复合 key 以调用方传入的权威 serverId 合成:对原始 id 做 splitSessionKey 会回退到
// 全局活动服务器,pane 绑定其他服务器时(多服务器 / WSL)孙 session 永远匹配不上
const childScoped = child.sessionKey.includes('::')
? child.sessionKey
: makeSessionKey(child.serverId, child.sessionKey)
const { serverId: childServerId, sessionId: childRawId } = splitSessionKey(childScoped)
const isMatch = (sid: string) => {
const { sessionId: raw } = splitSessionKey(sid)
// 消息 metadata 里的 sessionId 是原始 id,pending 请求的 sessionID 可能是复合 key(SSE)
// 或原始 id(轮询):统一按原始 id 比较,isChildOf 需要复合 key(childSessionStore 存复合)
if (raw === childRawId) return true
const scoped = sid.includes('::') ? sid : makeSessionKey(childServerId, raw)
return childSessionStore.isChildOf(scoped, childScoped)
Expand All @@ -75,19 +93,21 @@ export function findPermissionRequestForTool(

/**
* 根据 callID 查找关联的提问请求。
* 对于 task tool,额外传入 childSessionId
* 对于 task tool,额外传入 child(子 session key + pane 绑定的权威服务器)
*/
export function findQuestionRequestForTool(
pendingQuestions: ApiQuestionRequest[],
callID: string,
childSessionId?: string,
child?: TaskChildSessionRef,
): ApiQuestionRequest | undefined {
const direct = pendingQuestions.find(q => q.tool?.callID === callID)
if (direct) return direct

if (childSessionId) {
const { serverId: childServerId, sessionId: childRawId } = splitSessionKey(childSessionId)
const childScoped = childSessionId.includes('::') ? childSessionId : makeSessionKey(childServerId, childRawId)
if (child) {
const childScoped = child.sessionKey.includes('::')
? child.sessionKey
: makeSessionKey(child.serverId, child.sessionKey)
const { serverId: childServerId, sessionId: childRawId } = splitSessionKey(childScoped)
const isMatch = (sid: string) => {
const { sessionId: raw } = splitSessionKey(sid)
if (raw === childRawId) return true
Expand Down
4 changes: 2 additions & 2 deletions src/features/chat/ProjectDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export function ProjectDialog({ isOpen, onClose, onSelect, initialPath = '', ser
cancelled = true
clearTimeout(timer)
}
}, [isOpen, initialPath])
}, [isOpen, initialPath, serverId])

// ==========================================
// Load Directory
Expand Down Expand Up @@ -187,7 +187,7 @@ export function ProjectDialog({ isOpen, onClose, onSelect, initialPath = '', ser
cancelled = true
clearTimeout(timer)
}
}, [isOpen, currentDir])
}, [isOpen, currentDir, serverId])

// ==========================================
// Scroll to Selection
Expand Down
6 changes: 3 additions & 3 deletions src/features/chat/sidebar/SessionChildrenSlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export function SessionChildrenSlot({
cancelled = true
cancelAnimationFrame(loadingFrameId)
}
}, [fetchAll, parentSession.id, parentSession.directory])
}, [fetchAll, parentSession.id, parentSession.directory, serverId])

const handleRename = useCallback(async (childId: string, newTitle: string) => {
try {
Expand All @@ -83,7 +83,7 @@ export function SessionChildrenSlot({
} catch (e) {
uiErrorHandler('rename session', e)
}
}, [])
}, [parentSession.directory, serverId])

const handleDeleteConfirmed = useCallback(async () => {
const id = deleteConfirm.sessionId
Expand All @@ -99,7 +99,7 @@ export function SessionChildrenSlot({
} catch (e) {
uiErrorHandler('delete session', e)
}
}, [deleteConfirm.sessionId, selectedSessionId, onDeleteSelected])
}, [deleteConfirm.sessionId, selectedSessionId, onDeleteSelected, parentSession.directory, serverId])

const list = fetchAll ? fetched : givenChildren

Expand Down
15 changes: 9 additions & 6 deletions src/features/message/MessageRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
useInlineToolRequests,
findPermissionRequestForTool,
findQuestionRequestForTool,
type TaskChildSessionRef,
} from '../chat/InlineToolRequestContext'
import {
TextPartView,
Expand Down Expand Up @@ -967,14 +968,14 @@ const ToolGroup = memo(function ToolGroup({
}: ToolGroupProps) {
const { t } = useTranslation('message')
const { descriptiveToolSteps, inlineToolRequests, immersiveMode, processCollapseEnabled } = useTheme()
const { pendingPermissions, pendingQuestions } = useInlineToolRequests()
const { serverId, pendingPermissions, pendingQuestions } = useInlineToolRequests()
const hasPendingInteraction =
inlineToolRequests &&
parts.some(part => {
const childSessionId = getTaskChildSessionId(part)
const childSession = getTaskChildSessionRef(part, serverId)
return (
findPermissionRequestForTool(pendingPermissions, part.callID, childSessionId) ||
findQuestionRequestForTool(pendingQuestions, part.callID, childSessionId)
findPermissionRequestForTool(pendingPermissions, part.callID, childSession) ||
findQuestionRequestForTool(pendingQuestions, part.callID, childSession)
)
})

Expand Down Expand Up @@ -1367,10 +1368,12 @@ function isToolPartActive(part: ToolPart): boolean {
return part.state.status === 'running' || part.state.status === 'pending'
}

function getTaskChildSessionId(part: ToolPart): string | undefined {
/** task 工具派出的子 session:metadata 里是原始 id,服务器以 pane 绑定的 serverId 为权威 */
function getTaskChildSessionRef(part: ToolPart, serverId: string): TaskChildSessionRef | undefined {
if (part.tool.toLowerCase() !== 'task') return undefined
const metadata = part.state.metadata as Record<string, unknown> | undefined
return metadata?.sessionId as string | undefined
const sessionId = metadata?.sessionId as string | undefined
return sessionId ? { sessionKey: sessionId, serverId } : undefined
}

/** 从 extractToolData 的结果计算 diff stats(当 metadata 没给 diffStats 时) */
Expand Down
1 change: 1 addition & 0 deletions src/features/message/parts/ToolPartView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ vi.mock('../../../store/serverStore', () => ({

vi.mock('../../chat/InlineToolRequestContext', () => ({
useInlineToolRequests: () => ({
serverId: 'local',
pendingPermissions: [],
pendingQuestions: [],
onPermissionReply: vi.fn(),
Expand Down
Loading
Loading