diff --git a/packages/web/src/components/HubSessionStrategyEditor.tsx b/packages/web/src/components/HubSessionStrategyEditor.tsx index 02a415bd29..1b8c1c220e 100644 --- a/packages/web/src/components/HubSessionStrategyEditor.tsx +++ b/packages/web/src/components/HubSessionStrategyEditor.tsx @@ -2,23 +2,6 @@ import { SESSION_STRATEGY_OPTIONS, type StrategyFormState } from './hub-cat-editor.model'; import { RangeField, SelectField, TextField } from './hub-cat-editor-fields'; -import { SOURCE_LABELS } from './hub-strategy-types'; - -const REASON_LABELS: Record = { - managed_invocation_boundary: '需要受管 invocation 边界', - effective_input_ceiling: '缺少有效输入上限', - carrier_binding: '缺少当前 carrier 绑定', - authoritative_usage: '缺少权威 usage', - session_rotation: '缺少 Session 轮换能力', - continuity_bootstrap: '缺少连续性重建能力', - compression_signal: '缺少压缩事件信号', -}; - -function statusLabel(status: StrategyFormState['executionStatus']['status']): string { - if (status === 'active') return 'Active'; - if (status === 'degraded') return 'Degraded'; - return 'Unavailable'; -} export function HubSessionStrategyEditor({ strategyForm, @@ -31,10 +14,9 @@ export function HubSessionStrategyEditor({ error: string | null; onChange: (patch: Partial) => void; }) { - const statusMatchesDraft = strategyForm?.strategy === strategyForm?.statusStrategy; - return ( -
+
+

Session State / Chain

{loading ?

Session 策略加载中...

: null} {error ? (

@@ -42,74 +24,47 @@ export function HubSessionStrategyEditor({

) : null} {strategyForm ? ( -
-
-

Session State / Chain 始终记录并可见。

-

策略只表达你的意图;能力不足会显示执行状态,不会把策略静默改成另一种。

-

保存后的策略从下一次 invocation 起作用于当前 active session;正在运行的 invocation 保持原快照。

-
- -
- - {statusMatchesDraft ? '能力预检' : `已保存 ${strategyForm.statusStrategy} 策略的能力预检`}: - {statusLabel(strategyForm.executionStatus.status)} - - 来源:{SOURCE_LABELS[strategyForm.source] ?? strategyForm.source} -
- {!statusMatchesDraft ? ( -

- 保存后会重新预检 {strategyForm.strategy};当前状态不会被套用到未保存的策略。 -

- ) : strategyForm.executionStatus.missingCapabilities.length > 0 ? ( -

- {strategyForm.executionStatus.missingCapabilities - .map((reason) => REASON_LABELS[reason] ?? reason) - .join(';')} -

- ) : null} - -
- onChange({ strategy: value as StrategyFormState['strategy'] })} +
+ onChange({ strategy: value as StrategyFormState['strategy'] })} + tone="success" + /> + onChange({ warnThreshold: value })} + hint={ + strategyForm.strategy === 'compress' + ? '仅用于观测;compress 不触发 handoff' + : 'context 填充到此比例时发出警告' + } + /> + onChange({ actionThreshold: value })} + hint={ + strategyForm.strategy === 'compress' + ? '仅用于观测;Session 在 client 压缩后继续存活' + : '能力状态为 Active 时,达到此比例才执行当前策略动作' + } + /> + {strategyForm.strategy === 'hybrid' ? ( + onChange({ maxCompressions: value })} + inputMode="numeric" tone="success" /> - onChange({ warnThreshold: value })} - hint={ - strategyForm.strategy === 'compress' - ? '仅用于观测;compress 不触发 handoff' - : 'context 填充到此比例时发出警告' - } - /> - onChange({ actionThreshold: value })} - hint={ - strategyForm.strategy === 'compress' - ? '仅用于观测;Session 在 client 压缩后继续存活' - : '能力状态为 Active 时,达到此比例才执行当前策略动作' - } - /> - {strategyForm.strategy === 'hybrid' ? ( - onChange({ maxCompressions: value })} - inputMode="numeric" - tone="success" - /> - ) : null} -
+ ) : null}
) : null} -
+
); } diff --git a/packages/web/src/components/SessionChainPanel.tsx b/packages/web/src/components/SessionChainPanel.tsx index 5218af7caf..3f32c2077c 100644 --- a/packages/web/src/components/SessionChainPanel.tsx +++ b/packages/web/src/components/SessionChainPanel.tsx @@ -35,15 +35,6 @@ interface SessionSummary { cacheReadTokens?: number; costUsd?: number; }; - appliedPolicy?: { - config: { strategy: 'handoff' | 'compress' | 'hybrid' }; - source: string; - revision: string; - execution: { - status: 'active' | 'degraded' | 'unavailable'; - missingCapabilities: string[]; - }; - }; runtimeSession?: RuntimeSessionSummary; } @@ -126,9 +117,6 @@ function sealedSessionDetails(session: SessionSummary): string | undefined { ? `${session.compressionCount} compress` : '0 compress observed', session.sealReason ? sealReasonLabel(session.sealReason) : null, - session.appliedPolicy - ? `${session.appliedPolicy.config.strategy} · ${session.appliedPolicy.execution.status}` - : null, ].filter(Boolean); return details.length > 0 ? details.join(' · ') : undefined; } @@ -393,18 +381,6 @@ export function SessionChainPanel({ threadId, catInvocations, onViewSession }: S )} {session.compressionCount === 0 && · 0 compress observed}
- {session.appliedPolicy && ( -
- policy {session.appliedPolicy.config.strategy} - · {session.appliedPolicy.execution.status} - {session.appliedPolicy.execution.missingCapabilities.length > 0 && ( - · missing {session.appliedPolicy.execution.missingCapabilities.join(', ')} - )} -
- )} {session.runtimeSession && (
{ defaultModel = 'test-model', patch: Partial = {}, speed: { visible?: boolean; fastSupported?: boolean } = {}, + runtime: { + cat?: CatData | null; + strategyForm?: Parameters[0]['strategyForm']; + } = {}, ) { const form: HubCatEditorFormState = { catId: `runtime-${clientId}`, @@ -195,9 +199,9 @@ describe('HubCatEditor', () => { await act(async () => { root.render( React.createElement(AdvancedRuntimeSection, { - cat: null, + cat: runtime.cat ?? null, form, - strategyForm: null, + strategyForm: runtime.strategyForm ?? null, loadingStrategy: false, strategyError: null, codexSettings: null, @@ -216,6 +220,201 @@ describe('HubCatEditor', () => { return onChange; } + it('keeps Context Window focused on the Auto or Manual choice', async () => { + await renderAdvancedRuntimeSection( + 'openai', + 'gpt-5.6-sol', + {}, + {}, + { + cat: { + id: 'runtime-openai', + displayName: 'Runtime openai', + clientId: 'openai', + defaultModel: 'gpt-5.6-sol', + color: { primary: '#16a34a', secondary: '#bbf7d0' }, + mentionPatterns: ['@runtime-openai'], + avatar: '/avatars/default.png', + roleDescription: 'runtime config', + personality: 'test', + resolvedContext: { + ...actionableContextProjection, + source: 'reported', + provenance: 'internal carrier provenance', + }, + }, + }, + ); + + const input = queryField(container, 'input[aria-label="Context Window"]'); + expect(input.placeholder).toBe('留空或 0 = Auto;正整数 = Manual'); + expect(document.body.textContent).toContain( + '填写正整数 = Manual 模式,作为该成员的上下文窗口大小。留空或填 0 = Auto,由运行时自动探测。', + ); + expect(document.body.textContent).not.toContain('解析值'); + expect(document.body.textContent).not.toContain('模型目录'); + expect(document.body.textContent).not.toContain('internal carrier provenance'); + }); + + it('shows a concise action only when the selected client cannot resolve or apply Context Window', async () => { + const baseCat: CatData = { + id: 'runtime-antigravity', + displayName: 'Runtime antigravity', + clientId: 'antigravity', + defaultModel: 'test-model', + color: { primary: '#16a34a', secondary: '#bbf7d0' }, + mentionPatterns: ['@runtime-antigravity'], + avatar: '/avatars/default.png', + roleDescription: 'runtime config', + personality: 'test', + }; + + await renderAdvancedRuntimeSection( + 'antigravity', + 'test-model', + {}, + {}, + { + cat: { + ...baseCat, + resolvedContext: { + reportsRuntimeWindow: false, + nativeWindowControl: false, + authoritativeUsage: false, + usageTelemetry: 'unavailable', + nativeCompressionControl: false, + observesCompression: false, + capabilityReason: 'Concrete carrier capability unavailable', + }, + }, + }, + ); + + expect(document.body.textContent).toContain('当前 Client 无法自动探测上下文窗口;请填写正整数使用 Manual 模式。'); + expect(document.body.textContent).not.toContain('Concrete carrier capability unavailable'); + expect(document.body.textContent).not.toContain('未能解析 Context Window'); + + await renderAdvancedRuntimeSection( + 'antigravity', + 'test-model', + { contextWindow: '128000' }, + {}, + { + cat: { + ...baseCat, + resolvedContext: { + reportsRuntimeWindow: false, + nativeWindowControl: false, + authoritativeUsage: false, + usageTelemetry: 'unavailable', + nativeCompressionControl: false, + observesCompression: false, + }, + }, + }, + ); + + expect(document.body.textContent).toContain( + '当前 Client 无法校验或调整自身的上下文窗口;请确认 Manual 值不高于 Client 的实际上限。', + ); + expect(document.body.textContent).not.toContain('当前 Client 无法自动探测上下文窗口'); + + await renderAdvancedRuntimeSection( + 'antigravity', + 'test-model', + {}, + {}, + { + cat: { + ...baseCat, + resolvedContext: { + reportsRuntimeWindow: true, + nativeWindowControl: false, + authoritativeUsage: false, + usageTelemetry: 'conditional', + nativeCompressionControl: false, + observesCompression: false, + }, + }, + }, + ); + + expect(document.body.textContent).not.toContain('当前 Client 无法自动探测上下文窗口'); + expect(document.body.textContent).not.toContain('未能解析 Context Window'); + + await renderAdvancedRuntimeSection( + 'antigravity', + 'test-model', + {}, + {}, + { + cat: { + ...baseCat, + resolvedContext: { + source: 'catalog', + windowTokens: 128_000, + reportsRuntimeWindow: false, + nativeWindowControl: false, + authoritativeUsage: false, + usageTelemetry: 'unavailable', + nativeCompressionControl: false, + observesCompression: false, + }, + }, + }, + ); + + expect(document.body.textContent).not.toContain('当前 Client 无法自动探测上下文窗口'); + }); + + it.each([ + 'handoff', + 'compress', + 'hybrid', + ] as const)('renders Session State / Chain as a heading directly above the %s strategy controls', async (strategy) => { + await renderAdvancedRuntimeSection( + 'openai', + 'gpt-5.6-sol', + {}, + {}, + { + cat: { + id: 'runtime-openai', + displayName: 'Runtime openai', + clientId: 'openai', + defaultModel: 'gpt-5.6-sol', + color: { primary: '#16a34a', secondary: '#bbf7d0' }, + mentionPatterns: ['@runtime-openai'], + avatar: '/avatars/default.png', + roleDescription: 'runtime config', + personality: 'test', + resolvedContext: actionableContextProjection, + }, + strategyForm: { + strategy, + statusStrategy: strategy, + warnThreshold: '0.75', + actionThreshold: '0.85', + maxCompressions: '2', + source: 'runtime_override', + revision: 'test', + changedAt: 0, + executionStatus: { status: 'unavailable', missingCapabilities: ['authoritative_usage'] }, + }, + }, + ); + + const heading = document.body.querySelector('h3'); + const strategySelect = queryField(container, 'select[aria-label="Session Strategy"]'); + expect(heading?.textContent).toBe('Session State / Chain'); + expect(heading?.nextElementSibling?.contains(strategySelect)).toBe(true); + expect(strategySelect.value).toBe(strategy); + expect(Array.from(strategySelect.options, (option) => option.value)).toEqual(['handoff', 'compress', 'hybrid']); + expect(document.body.textContent).not.toContain('能力预检'); + expect(document.body.textContent).not.toContain('来源:'); + expect(document.body.textContent).not.toContain('缺少权威 usage'); + }); + it('shows extra CLI args editor for CLI clients and hides it for API-only clients', async () => { for (const clientId of ['anthropic', 'openai', 'google', 'kimi', 'opencode'] as const) { await renderAdvancedRuntimeSection(clientId); @@ -3481,7 +3680,11 @@ describe('HubCatEditor', () => { expect(document.body.textContent).toContain('展开后可配置 TTS clone 参考音频和文本。'); expect(document.body.textContent).toContain('别名与 @ 路由'); expect(document.body.textContent).toContain('认证与模型'); - expect(document.body.textContent).toContain('Session State / Chain 始终记录并可见'); + expect( + Array.from(document.body.querySelectorAll('h3')).some( + (heading) => heading.textContent === 'Session State / Chain', + ), + ).toBe(true); expect(document.body.textContent).toContain('── Codex 专属 (仅 Client=Codex 时显示) ──'); expect(document.body.textContent).toContain('Codex Sandbox (Codex)'); expect(document.body.textContent).toContain('Codex Approval (Codex)'); @@ -3504,8 +3707,8 @@ describe('HubCatEditor', () => { await changeField(queryField(container, 'input[aria-label="Team Strengths"]'), '代码审查、找 bug、深度思考'); await changeField(queryField(container, 'input[aria-label="Strengths"]'), 'security, testing, debugging'); await changeField(queryField(container, 'select[aria-label="Session Strategy"]'), 'handoff', 'change'); - expect(document.body.textContent).toContain('已保存 compress 策略的能力预检'); - expect(document.body.textContent).toContain('保存后会重新预检 handoff'); + expect(document.body.textContent).not.toContain('能力预检'); + expect(document.body.textContent).not.toContain('保存后会重新预检 handoff'); await changeField(queryField(container, 'input[aria-label="Session Warn Threshold"]'), '0.55', 'change'); await changeField(queryField(container, 'select[aria-label^="Codex Sandbox"]'), 'danger-full-access', 'change'); await changeField(queryField(container, 'select[aria-label^="Codex Approval"]'), 'never', 'change'); @@ -3731,7 +3934,11 @@ describe('HubCatEditor', () => { }); await flushEffects(); - expect(document.body.textContent).toContain('Session State / Chain 始终记录并可见'); + expect( + Array.from(document.body.querySelectorAll('h3')).some( + (heading) => heading.textContent === 'Session State / Chain', + ), + ).toBe(true); expect(document.body.textContent).not.toContain('Session Chain 未开启'); expect(queryField(container, 'select[aria-label="Session Strategy"]').value).toBe('compress'); expect(document.body.querySelector('input[aria-label="Session Observe Threshold"]')).toBeTruthy(); diff --git a/packages/web/src/components/__tests__/session-chain-panel.test.ts b/packages/web/src/components/__tests__/session-chain-panel.test.ts index 9cc734d373..d9c9f67753 100644 --- a/packages/web/src/components/__tests__/session-chain-panel.test.ts +++ b/packages/web/src/components/__tests__/session-chain-panel.test.ts @@ -258,10 +258,10 @@ describe('F24: SessionChainPanel', () => { expect(container.textContent).toContain('0 compress observed'); }); - it('shows the applied policy and exact execution status on the active session node', async () => { + it('keeps applied policy details out of active cards and sealed summaries', async () => { mockSessionsResponse([ { - id: 'policy-node', + id: 'active-policy-node', catId: 'opus', seq: 0, status: 'active', @@ -279,16 +279,51 @@ describe('F24: SessionChainPanel', () => { }, }, }, + { + id: 'sealed-policy-node', + catId: 'codex', + seq: 1, + status: 'sealed', + messageCount: 12, + compressionCount: 2, + contextHealth: { + usedTokens: 94_720, + windowTokens: 128_000, + fillRatio: 0.74, + source: 'exact', + }, + sealReason: 'threshold', + createdAt: Date.now() - 60_000, + sealedAt: Date.now() - 30_000, + appliedPolicy: { + config: { strategy: 'handoff', thresholds: { warn: 0.8, action: 0.9 } }, + source: 'runtime_override', + revision: 'runtime:handoff:1', + changedAt: 1, + execution: { + status: 'unavailable', + missingCapabilities: ['authoritative_usage'], + }, + }, + }, ]); renderPanel('thread-policy-state'); await flushFetch(); - const policyState = container.querySelector('[data-testid="session-policy-state"]'); - expect(policyState?.textContent).toContain('hybrid'); - expect(policyState?.textContent).toContain('degraded'); - expect(policyState?.textContent).toContain('compression_signal'); - expect(policyState?.textContent).toContain('session_rotation'); + expect(container.querySelector('[data-testid="session-policy-state"]')).toBeNull(); + expect(container.textContent).not.toContain('compression_signal'); + expect(container.textContent).not.toContain('session_rotation'); + + expandSealed(); + const sealedSummary = container.querySelector('[data-testid="sealed-session-summary"]'); + await act(async () => sealedSummary?.querySelector('button[aria-expanded="false"]')?.click()); + expect(sealedSummary?.textContent).toContain('74%'); + expect(sealedSummary?.textContent).toContain('2 compress'); + expect(sealedSummary?.textContent).toContain('threshold'); + expect(sealedSummary?.textContent).not.toContain('handoff'); + expect(sealedSummary?.textContent).not.toContain('unavailable'); + expect(sealedSummary?.textContent).not.toContain('authoritative_usage'); }); it('collapses repeated 0-msg tool_conflict retry corpses into one summary (F201-churn)', async () => { diff --git a/packages/web/src/components/hub-cat-editor-advanced.tsx b/packages/web/src/components/hub-cat-editor-advanced.tsx index bb7212c36f..437cd1e200 100644 --- a/packages/web/src/components/hub-cat-editor-advanced.tsx +++ b/packages/web/src/components/hub-cat-editor-advanced.tsx @@ -71,12 +71,12 @@ export function AdvancedRuntimeSection({ onChange={(value) => onChange({ contextWindow: value })} inputMode="numeric" tone="success" - placeholder="留空或 0 = Auto(由 CLI / 模型目录自动探测)" + placeholder="留空或 0 = Auto;正整数 = Manual" />

填写正整数 = Manual 模式,作为该成员的上下文窗口大小。留空或填 0 = Auto,由运行时自动探测。

- + {cliExtensionsAvailable && cliEffortOptions ? ( <> = { - reported: 'CLI 实时上报', - manual: '手动设置', - catalog: '模型目录', -}; - -const SOURCE_BADGES: Record = { - reported: '✓ 运行时', - manual: '✓ 手动', - catalog: '≈ 目录', -}; - -/** Show resolved context window info below the Context Window field. */ -function ResolvedContextInfo({ cat }: { cat?: CatData | null }) { - if (!cat) return null; - - const rc = cat.resolvedContext; - if (!rc || !rc.source) { - return ( -
-

⚠ 未能解析 Context Window — 无已知的模型目录条目或 CLI 上报值。

- {rc?.capabilityReason ?

原因: {rc.capabilityReason}

: null} -
- ); - } - - const badge = SOURCE_BADGES[rc.source] ?? rc.source; - const sourceLabel = SOURCE_LABELS[rc.source] ?? rc.source; - const windowK = rc.windowTokens ? Math.round(rc.windowTokens / 1000) : 0; + const manualWindow = Number(contextWindow.trim()); + const isManual = Number.isInteger(manualWindow) && manualWindow > 0; + if (!isManual && capability.source) return null; return ( -
-

- {badge} - {' · '} - 解析值: {windowK}K tokens - {' · '} - 来源: {sourceLabel} - {rc.actionable ? '' : '(仅供参考,不触发自动 Session 操作)'} -

-

{rc.provenance}

-
+

+ {isManual + ? '当前 Client 无法校验或调整自身的上下文窗口;请确认 Manual 值不高于 Client 的实际上限。' + : '当前 Client 无法自动探测上下文窗口;请填写正整数使用 Manual 模式。'} +

); }