diff --git a/build/installer.nsh b/build/installer.nsh index 185964316..5da7ba55f 100644 --- a/build/installer.nsh +++ b/build/installer.nsh @@ -17,6 +17,7 @@ Var /GLOBAL KunInstallerPreserveOtherScope Var /GLOBAL KunInstallerOtherUninstallString Var /GLOBAL KunInstallerOtherQuietUninstallString Var /GLOBAL KunInstallerRestoreInteractive +Var /GLOBAL KunInstallerInPlaceUpdate Var /GLOBAL KunInstallerCurrentUserShortcutName Var /GLOBAL KunInstallerCurrentUserMenuDirectory !endif @@ -77,6 +78,7 @@ Var /GLOBAL KunInstallerStopResult StrCpy $KunInstallerOtherQuietUninstallString "" !ifndef BUILD_UNINSTALLER StrCpy $KunInstallerRestoreInteractive 0 + StrCpy $KunInstallerInPlaceUpdate 0 !endif ${if} ${isUpdated} @@ -157,7 +159,14 @@ Var /GLOBAL KunInstallerStopResult !macroend !macro customUnInstallCheck - ${if} $KunInstallerPrimarySourceStale != 1 + ${if} $KunInstallerInPlaceUpdate == 1 + # Same-directory automatic updates overwrite in place. Running the old + # uninstaller or FallbackCleanup first can empty the program directory when + # the subsequent extract/validate step fails. + ClearErrors + StrCpy $R0 0 + DetailPrint "In-place automatic update; skipping pre-install removal of $KunInstallerPrimarySourceDir." + ${elseIf} $KunInstallerPrimarySourceStale != 1 StrCpy $KunInstallerSourceDir $KunInstallerPrimarySourceDir Call KunHandleOldUninstallerResult ${else} @@ -214,6 +223,13 @@ Var /GLOBAL KunInstallerStopResult Quit ${endif} + ${if} $KunInstallerInPlaceUpdate == 1 + !insertmacro kunRunMigrationHelper CleanupInPlaceLeftovers + ${if} $KunInstallerHelperExitCode != 0 + DetailPrint "Kun could not remove obsolete in-place update leftovers: $KunInstallerHelperOutput" + ${endif} + ${endif} + !insertmacro kunRunMigrationHelper UpdatePath ${if} $KunInstallerHelperExitCode != 0 DetailPrint "Kun could not update the user PATH: $KunInstallerHelperOutput" @@ -256,6 +272,7 @@ Var /GLOBAL KunInstallerStopResult System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_PRIMARY_SOURCE_STALE", "$KunInstallerPrimarySourceStale").r0' System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_SECONDARY_SOURCE_STALE", "$KunInstallerSecondarySourceStale").r0' System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_CANDIDATE_EXPLICIT", "$KunInstallerCandidateExplicit").r0' + System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_IN_PLACE_UPDATE", "$KunInstallerInPlaceUpdate").r0' System::Call 'kernel32::SetEnvironmentVariable(t, t)i ("KUN_INSTALLER_INSTALL_MODE", "$installMode").r0' FunctionEnd @@ -528,6 +545,7 @@ Var /GLOBAL KunInstallerStopResult Call KunRetireSelectedShellState ${else} StrCpy $KunInstallerSourceDir $KunInstallerPrimarySourceDir + Call KunMarkInPlaceAutomaticUpdate Call KunSecureSelectedUninstallRegistration ${endif} ${if} $KunInstallerHelperOutput == "2" @@ -543,6 +561,20 @@ Var /GLOBAL KunInstallerStopResult StrCpy $KunInstallerMigrationPrepared 1 FunctionEnd + Function KunMarkInPlaceAutomaticUpdate + StrCpy $KunInstallerInPlaceUpdate 0 + ${ifNot} ${isUpdated} + Return + ${endif} + ${if} $KunInstallerPrimarySourceDir == "" + Return + ${endif} + ${if} $KunInstallerPrimarySourceDir == $KunInstallerTargetDir + StrCpy $KunInstallerInPlaceUpdate 1 + DetailPrint "Automatic update will overwrite $KunInstallerTargetDir in place without pre-deleting the application payload." + ${endif} + FunctionEnd + Function KunSuspendCurrentUserUninstallRegistration ReadRegStr $KunInstallerOtherUninstallString HKEY_CURRENT_USER "${UNINSTALL_REGISTRY_KEY}" UninstallString ReadRegStr $KunInstallerOtherQuietUninstallString HKEY_CURRENT_USER "${UNINSTALL_REGISTRY_KEY}" QuietUninstallString @@ -575,6 +607,14 @@ Var /GLOBAL KunInstallerStopResult FunctionEnd Function KunSecureSelectedUninstallRegistration + ${if} $KunInstallerInPlaceUpdate == 1 + # Hide the old uninstaller from electron-builder so it cannot wipe the + # same directory before the new payload is written and validated. + DeleteRegValue SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}" UninstallString + DeleteRegValue SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}" QuietUninstallString + DetailPrint "In-place automatic update; suppressed the selected-scope uninstaller until the new payload is installed." + Return + ${endif} Call KunResolveTrustedUninstaller ${if} $KunInstallerHelperOutput == "" DeleteRegValue SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}" UninstallString diff --git a/build/windows-installer-migration.ps1 b/build/windows-installer-migration.ps1 index daad715e1..23f802671 100644 --- a/build/windows-installer-migration.ps1 +++ b/build/windows-installer-migration.ps1 @@ -1,6 +1,6 @@ param( [Parameter(Mandatory = $true)] - [ValidateSet('ResolvePath', 'ResolveSource', 'ResolveUpdateScope', 'ResolveUninstaller', 'StopProcesses', 'Recover', 'Prepare', 'FallbackCleanup', 'Restore', 'ValidatePayload', 'UpdatePath')] + [ValidateSet('ResolvePath', 'ResolveSource', 'ResolveUpdateScope', 'ResolveUninstaller', 'StopProcesses', 'Recover', 'Prepare', 'FallbackCleanup', 'Restore', 'ValidatePayload', 'CleanupInPlaceLeftovers', 'UpdatePath')] [string]$Action, [string]$ResultPath = '' ) @@ -939,6 +939,93 @@ function Assert-PackagedInstallPayload { ) 'the unpacked Kun service manager entry' } +function Test-InPlaceUpdateRequested { + return [string]::Equals( + (Get-EnvironmentValue 'KUN_INSTALLER_IN_PLACE_UPDATE').Trim(), + '1', + [StringComparison]::Ordinal + ) +} + +function Get-CurrentProductUninstallerFile { + $configured = (Get-EnvironmentValue 'KUN_INSTALLER_PRODUCT_NAME').Trim() + if (-not [string]::IsNullOrWhiteSpace($configured)) { + return 'Uninstall ' + $configured + '.exe' + } + return 'Uninstall ' + (Get-CanonicalLeaf) + '.exe' +} + +function Test-RetainedInPlaceKnownEntry([IO.FileSystemInfo]$Entry) { + if ($Entry.PSIsContainer) { + # Keep packaged directories that the new payload still uses. + return @('resources', 'locales', 'bin') -contains $Entry.Name.ToLowerInvariant() + } + + $expectedExecutable = Get-ExpectedApplicationExecutable + if ([string]::Equals($Entry.Name, $expectedExecutable, [StringComparison]::OrdinalIgnoreCase)) { + return $true + } + + $currentUninstaller = Get-CurrentProductUninstallerFile + if ([string]::Equals($Entry.Name, $currentUninstaller, [StringComparison]::OrdinalIgnoreCase)) { + return $true + } + + # Electron runtime files from the newly extracted package stay in place. + $runtimeFiles = @( + 'uninstallericon.ico', + 'chrome_100_percent.pak', + 'chrome_200_percent.pak', + 'd3dcompiler_47.dll', + 'dxcompiler.dll', + 'dxil.dll', + 'ffmpeg.dll', + 'icudtl.dat', + 'libegl.dll', + 'libglesv2.dll', + 'license.electron.txt', + 'licenses.chromium.html', + 'resources.pak', + 'snapshot_blob.bin', + 'v8_context_snapshot.bin', + 'vk_swiftshader.dll', + 'vk_swiftshader_icd.json', + 'vulkan-1.dll' + ) + return $runtimeFiles -contains $Entry.Name.ToLowerInvariant() +} + +function Invoke-CleanupInPlaceLeftovers { + if (-not (Test-InPlaceUpdateRequested)) { + return + } + + $target = Normalize-FullPath (Get-EnvironmentValue 'KUN_INSTALLER_TARGET') + $source = Normalize-FullPath (Get-EnvironmentValue 'KUN_INSTALLER_SOURCE') + if ([string]::IsNullOrWhiteSpace($target)) { + throw 'KUN_INSTALLER_TARGET is required for in-place leftover cleanup.' + } + if (-not [string]::IsNullOrWhiteSpace($source) -and -not (Test-PathEqual $source $target)) { + throw "In-place leftover cleanup requires the source and target to match: $source -> $target" + } + + Assert-PackagedInstallPayload + + $legacyEntries = @(Get-ChildItem -LiteralPath $target -Force | Where-Object { + (Test-KnownApplicationEntry $_) -and -not (Test-RetainedInPlaceKnownEntry $_) + }) + foreach ($entry in $legacyEntries) { + if ($entry.PSIsContainer) { + Assert-NoReparsePointsInTree $entry 'Obsolete in-place application directory' + } elseif (Test-ReparsePoint $entry.FullName) { + throw "Obsolete in-place application file is a reparse point: $($entry.FullName)" + } + } + foreach ($entry in $legacyEntries) { + Remove-KnownApplicationEntry $entry + } +} + function Test-AppSpecificUninstaller([string]$Source) { if ([string]::IsNullOrWhiteSpace($Source)) { return $false @@ -1384,6 +1471,9 @@ try { 'ValidatePayload' { Assert-PackagedInstallPayload } + 'CleanupInPlaceLeftovers' { + Invoke-CleanupInPlaceLeftovers + } 'UpdatePath' { Update-UserPath } diff --git a/kun/benchmarks/agent-core.json b/kun/benchmarks/agent-core.json index 66d078d5c..3f05c2785 100644 --- a/kun/benchmarks/agent-core.json +++ b/kun/benchmarks/agent-core.json @@ -10,121 +10,121 @@ "id": "architecture-summary", "tags": ["smoke", "architecture"], "prompt": "Read the repository and explain the active Renderer -> preload -> main -> Kun runtime data path. Cite the most relevant file paths. Do not modify files.", - "expect": { "requiredAnyTools": ["read", "grep", "find", "ls"] } + "expect": { "requiredTools": ["explore_agent"], "requiredAnyTools": ["explore_agent", "read", "grep", "find", "ls"] } }, { "id": "runtime-entrypoint", "tags": ["smoke", "runtime"], "prompt": "Find the Kun serve-mode composition root and summarize how stores, model clients, tools, and the agent loop are assembled. Cite exact file paths. Do not modify files.", - "expect": { "requiredAnyTools": ["read", "grep", "find", "ls"] } + "expect": { "requiredAnyTools": ["explore_agent", "read", "grep", "find", "ls"] } }, { "id": "renderer-send-flow", "tags": ["smoke", "frontend"], "prompt": "Trace a chat message from the renderer composer through the preload/main bridge to the Kun turn endpoint. Return a concise ordered call path with files. Do not modify files.", - "expect": { "requiredAnyTools": ["read", "grep", "find", "ls"] } + "expect": { "requiredAnyTools": ["explore_agent", "read", "grep", "find", "ls"] } }, { "id": "sse-replay", "tags": ["smoke", "runtime"], "prompt": "Explain how Kun SSE event replay avoids duplicates and cursor rewind after reconnect or restart. Cite the implementation and tests. Do not modify files.", - "expect": { "requiredAnyTools": ["read", "grep", "find", "ls"] } + "expect": { "requiredAnyTools": ["explore_agent", "read", "grep", "find", "ls"] } }, { "id": "mcp-lifecycle", "tags": ["smoke", "mcp"], "prompt": "Inspect MCP startup, tool discovery, execution, and reconnect behavior. Identify the main reliability boundaries and cite the implementation files. Do not modify files.", - "expect": { "requiredAnyTools": ["read", "grep", "find", "ls"] } + "expect": { "requiredAnyTools": ["explore_agent", "read", "grep", "find", "ls"] } }, { "id": "cache-prefix", "tags": ["cache"], "prompt": "Explain what makes Kun's immutable prompt prefix stable and list dynamic data that must remain outside it. Cite code and documentation. Do not modify files.", - "expect": { "requiredAnyTools": ["read", "grep", "find", "ls"] } + "expect": { "requiredAnyTools": ["explore_agent", "read", "grep", "find", "ls"] } }, { "id": "provider-url-contract", "tags": ["provider"], "prompt": "Trace how baseUrl and endpointFormat affect provider URL construction and request bodies across chat and auxiliary model calls. Cite all important consumers. Do not modify files.", - "expect": { "requiredAnyTools": ["read", "grep", "find", "ls"] } + "expect": { "requiredAnyTools": ["explore_agent", "read", "grep", "find", "ls"] } }, { "id": "attachment-flow", "tags": ["attachments"], "prompt": "Trace an image or local file attachment from renderer selection to model input or fallback. Identify the cross-layer contract fields and failure points. Do not modify files.", - "expect": { "requiredAnyTools": ["read", "grep", "find", "ls"] } + "expect": { "requiredAnyTools": ["explore_agent", "read", "grep", "find", "ls"] } }, { "id": "approval-flow", "tags": ["runtime", "security"], "prompt": "Trace a tool approval request from agent loop creation through SSE/UI resolution back to tool execution. Cite routes, gates, and renderer handlers. Do not modify files.", - "expect": { "requiredAnyTools": ["read", "grep", "find", "ls"] } + "expect": { "requiredAnyTools": ["explore_agent", "read", "grep", "find", "ls"] } }, { "id": "goal-resume", "tags": ["runtime", "goal"], "prompt": "Explain how active goals survive runtime restart, how orphaned turns are reconciled, and where auto-resume is triggered. Cite tests if present. Do not modify files.", - "expect": { "requiredAnyTools": ["read", "grep", "find", "ls"] } + "expect": { "requiredAnyTools": ["explore_agent", "read", "grep", "find", "ls"] } }, { "id": "subagent-permissions", "tags": ["subagent", "security"], "prompt": "Explain how subagent tool policies inherit or restrict built-in tools, MCP servers, and skills without escalating the parent permissions. Cite enforcement points. Do not modify files.", - "expect": { "requiredAnyTools": ["read", "grep", "find", "ls"] } + "expect": { "requiredAnyTools": ["explore_agent", "read", "grep", "find", "ls"] } }, { "id": "settings-persistence", "tags": ["settings"], "prompt": "Trace a Kun settings change from renderer state through validation/persistence to managed runtime restart. Highlight rollback behavior. Do not modify files.", - "expect": { "requiredAnyTools": ["read", "grep", "find", "ls"] } + "expect": { "requiredAnyTools": ["explore_agent", "read", "grep", "find", "ls"] } }, { "id": "test-selection", "tags": ["quality"], "prompt": "Identify how the verify_changes tool selects and runs validation after edits. Explain its safety limits and output contract. Do not modify files.", - "expect": { "requiredAnyTools": ["read", "grep", "find", "ls"] } + "expect": { "requiredAnyTools": ["explore_agent", "read", "grep", "find", "ls"] } }, { "id": "build-pipeline", "tags": ["build"], "prompt": "Summarize the development, typecheck, test, build, and packaging pipeline for Kun. Cite package scripts and packaging configuration. Do not modify files.", - "expect": { "requiredAnyTools": ["read", "grep", "find", "ls"] } + "expect": { "requiredAnyTools": ["explore_agent", "read", "grep", "find", "ls"] } }, { "id": "security-boundaries", "tags": ["security"], "prompt": "Map the main trust boundaries for renderer IPC, filesystem tools, command execution, MCP, and secrets. Cite concrete enforcement files. Do not modify files.", - "expect": { "requiredAnyTools": ["read", "grep", "find", "ls"] } + "expect": { "requiredAnyTools": ["explore_agent", "read", "grep", "find", "ls"] } }, { "id": "runtime-hotspots", "tags": ["performance"], "prompt": "Inspect runtime event persistence, SSE replay, tool execution, and context assembly. Identify three evidence-based performance or memory hotspots with file references. Do not modify files.", - "expect": { "requiredAnyTools": ["read", "grep", "find", "ls"] } + "expect": { "requiredAnyTools": ["explore_agent", "read", "grep", "find", "ls"] } }, { "id": "thread-persistence", "tags": ["storage"], "prompt": "Explain how thread/session data is persisted and indexed across file and hybrid SQLite stores, including usage carryover. Cite implementation files. Do not modify files.", - "expect": { "requiredAnyTools": ["read", "grep", "find", "ls"] } + "expect": { "requiredAnyTools": ["explore_agent", "read", "grep", "find", "ls"] } }, { "id": "model-capabilities", "tags": ["provider"], "prompt": "Explain how model capabilities control image input, tool calling, reasoning effort, endpoint format, and context limits. Cite schemas and request construction. Do not modify files.", - "expect": { "requiredAnyTools": ["read", "grep", "find", "ls"] } + "expect": { "requiredAnyTools": ["explore_agent", "read", "grep", "find", "ls"] } }, { "id": "frontend-chunking", "tags": ["frontend", "performance"], "prompt": "Inspect renderer lazy loading and identify which Workbench surfaces are split into separate chunks and which heavy chat dependencies still load eagerly. Do not modify files.", - "expect": { "requiredAnyTools": ["read", "grep", "find", "ls"] } + "expect": { "requiredAnyTools": ["explore_agent", "read", "grep", "find", "ls"] } }, { "id": "failure-recovery", "tags": ["runtime", "reliability"], "prompt": "Map how the desktop app detects an unhealthy Kun child, budgets restarts, distinguishes settings restarts from crashes, and reports status to the renderer. Do not modify files.", - "expect": { "requiredAnyTools": ["read", "grep", "find", "ls"] } + "expect": { "requiredAnyTools": ["explore_agent", "read", "grep", "find", "ls"] } } ] } diff --git a/kun/src/adapters/model/compat-message-projector.ts b/kun/src/adapters/model/compat-message-projector.ts index ee0af03ee..b6e40ed11 100644 --- a/kun/src/adapters/model/compat-message-projector.ts +++ b/kun/src/adapters/model/compat-message-projector.ts @@ -8,6 +8,7 @@ import { extractToolResultImages, toolResultTextWithoutImages } from '../../loop import { wrapUntrustedContent } from '../../security/untrusted-content.js' import { COMPAT_ANTHROPIC_THINKING, + COMPAT_TOOL_RESULT_ERROR, COMPAT_HISTORY_CONTEXT, type CompatChatMessage, type CompatChatMessageContentPart @@ -248,7 +249,8 @@ class CompatMessageProjector { return { role: 'tool', content: text || '(image omitted: the active model has no image input)', - tool_call_id: item.callId + tool_call_id: item.callId, + ...(item.isError === true ? { [COMPAT_TOOL_RESULT_ERROR]: true } : {}) } } const parts: CompatChatMessageContentPart[] = [] @@ -259,12 +261,18 @@ class CompatMessageProjector { image_url: { url: `data:${image.mimeType};base64,${image.dataBase64}` } }) } - return { role: 'tool', content: parts, tool_call_id: item.callId } + return { + role: 'tool', + content: parts, + tool_call_id: item.callId, + ...(item.isError === true ? { [COMPAT_TOOL_RESULT_ERROR]: true } : {}) + } } return { role: 'tool', content: toolResultContent(item.output), - tool_call_id: item.callId + tool_call_id: item.callId, + ...(item.isError === true ? { [COMPAT_TOOL_RESULT_ERROR]: true } : {}) } } diff --git a/kun/src/adapters/model/compat-model-client.endpoint-format.test.ts b/kun/src/adapters/model/compat-model-client.endpoint-format.test.ts index 471da3913..15b8b9045 100644 --- a/kun/src/adapters/model/compat-model-client.endpoint-format.test.ts +++ b/kun/src/adapters/model/compat-model-client.endpoint-format.test.ts @@ -568,15 +568,19 @@ describe('CompatModelClient per-model endpointFormat', () => { }) it('uses the Codex Responses Lite shape for GPT-5.6 models', async () => { - const calls: Array<{ headers: Record; body: Record }> = [] + const calls: Array<{ url: string; headers: Record; body: Record }> = [] const client = new CompatModelClient({ baseUrl: 'https://chatgpt.com/backend-api/codex', apiKey: 'oauth-access-token', model: 'gpt-5.6-sol', endpointFormat: 'responses', nonStreaming: true, - fetchImpl: (async (_url: string, init: { headers: Record; body: string }) => { - calls.push({ headers: init.headers, body: JSON.parse(init.body) as Record }) + fetchImpl: (async (url: string, init: { headers: Record; body: string }) => { + calls.push({ + url: String(url), + headers: init.headers, + body: JSON.parse(init.body) as Record + }) return new Response(JSON.stringify({ output_text: 'ok' }), { status: 200, headers: { 'content-type': 'application/json' } @@ -605,6 +609,7 @@ describe('CompatModelClient per-model endpointFormat', () => { }] })) + expect(calls[0].url).toBe('https://chatgpt.com/backend-api/codex/responses') expect(calls[0].headers['x-openai-internal-codex-responses-lite']).toBe('true') expect(calls[0].body).toMatchObject({ model: 'gpt-5.6-sol', @@ -628,6 +633,34 @@ describe('CompatModelClient per-model endpointFormat', () => { expect(input[1]).toMatchObject({ type: 'message', role: 'developer' }) }) + it('normalizes legacy Codex baseUrl + responses format to the custom /responses endpoint', async () => { + const calls: Array<{ url: string; body: Record }> = [] + const client = new CompatModelClient({ + baseUrl: 'https://chatgpt.com/backend-api/codex', + apiKey: 'oauth-access-token', + model: 'gpt-5.5', + endpointFormat: 'responses', + nonStreaming: true, + fetchImpl: (async (url: string, init: { body: string }) => { + calls.push({ url: String(url), body: JSON.parse(init.body) as Record }) + return new Response(JSON.stringify({ output_text: 'ok' }), { + status: 200, + headers: { 'content-type': 'application/json' } + }) + }) as unknown as typeof fetch, + modelCapabilities: modelCapabilities({}) + }) + + await drain(client.stream(request('gpt-5.5'))) + + expect(calls[0].url).toBe('https://chatgpt.com/backend-api/codex/responses') + expect(calls[0].body).toMatchObject({ + model: 'gpt-5.5', + store: false + }) + expect(calls[0].body).not.toHaveProperty('messages') + }) + it('keeps GPT-5.6 Responses Lite cache inputs append-only and thread-scoped', async () => { const calls: Array<{ headers: Record; body: Record }> = [] const responses = [ diff --git a/kun/src/adapters/model/compat-model-client.ts b/kun/src/adapters/model/compat-model-client.ts index 4cee5721d..aa1cdefe2 100644 --- a/kun/src/adapters/model/compat-model-client.ts +++ b/kun/src/adapters/model/compat-model-client.ts @@ -171,6 +171,25 @@ function isCodexEndpoint(baseUrl: string): boolean { return baseUrl.includes('chatgpt.com/backend-api/codex') } +function normalizeCodexResponsesUrl(baseUrl: string): string { + try { + const url = new URL(baseUrl.trim()) + if ( + url.protocol !== 'https:' || + url.hostname !== 'chatgpt.com' || + !url.pathname.replace(/\/+$/u, '').startsWith('/backend-api/codex') + ) { + return exactModelEndpointUrl(baseUrl) + } + url.pathname = '/backend-api/codex/responses' + url.search = '' + url.hash = '' + return url.toString() + } catch { + return exactModelEndpointUrl(baseUrl) + } +} + /** * Multi-provider HTTP model client. * @@ -257,7 +276,17 @@ export class CompatModelClient implements ModelClient { // OpenCode Go) can route some models to chat completions and others to // Anthropic Messages. Falls back to the provider/runtime format. const configuredEndpointFormat = this.endpointFormatForModel(requestModel) - const endpointFormat = resolveModelEndpointFormat(configuredEndpointFormat, this.config.baseUrl) + const isCodex = isCodexEndpoint(this.config.baseUrl) + // Legacy Codex profiles stored `.../codex` + `responses` (or a bare + // custom path without `/responses`). Normalize before format inference so + // chat does not fail the custom-endpoint suffix check or hit `/v1/responses`. + const resolveBaseUrl = isCodex + ? normalizeCodexResponsesUrl(this.config.baseUrl) + : this.config.baseUrl + const endpointFormat = resolveModelEndpointFormat( + isCodex ? 'custom_endpoint' : configuredEndpointFormat, + resolveBaseUrl + ) if (!endpointFormat) { yield { kind: 'error', @@ -1311,6 +1340,7 @@ export class CompatModelClient implements ModelClient { } function buildModelEndpointUrl(baseUrl: string, endpointFormat: ModelEndpointFormat): string { + if (isCodexEndpoint(baseUrl)) return normalizeCodexResponsesUrl(baseUrl) if (isCustomModelEndpointFormat(endpointFormat)) return exactModelEndpointUrl(baseUrl) const path = modelEndpointPath(endpointFormat) const normalized = baseUrl.trim().replace(/\/+$/, '') diff --git a/kun/src/adapters/model/compat-request-builder.ts b/kun/src/adapters/model/compat-request-builder.ts index 59797bada..9060ef8f7 100644 --- a/kun/src/adapters/model/compat-request-builder.ts +++ b/kun/src/adapters/model/compat-request-builder.ts @@ -4,6 +4,7 @@ import { isDeepSeekHost } from './model-error-probe.js' import { repairToolArguments } from './tool-argument-repair.js' import { COMPAT_ANTHROPIC_THINKING, + COMPAT_TOOL_RESULT_ERROR, CompatRequestCodecs, type CompatChatMessage, type CompatChatMessageContentPart @@ -19,7 +20,7 @@ type AnthropicContentBlock = ( | { type: 'thinking'; thinking: string; signature: string } | { type: 'redacted_thinking'; data: string } | { type: 'tool_use'; id: string; name: string; input: Record } - | { type: 'tool_result'; tool_use_id: string; content: string } + | { type: 'tool_result'; tool_use_id: string; content: string; is_error?: boolean } ) & { cache_control?: AnthropicCacheControl } type AnthropicMessage = { role: 'user' | 'assistant' @@ -133,7 +134,8 @@ function messagesToAnthropic( const blocks: AnthropicContentBlock[] = [{ type: 'tool_result', tool_use_id: message.tool_call_id, - content: chatContentToTextOnly(message.content) + content: chatContentToTextOnly(message.content), + ...(message[COMPAT_TOOL_RESULT_ERROR] === true ? { is_error: true } : {}) }] if (Array.isArray(message.content)) { for (const part of message.content) { diff --git a/kun/src/adapters/model/compat-request-codecs.ts b/kun/src/adapters/model/compat-request-codecs.ts index 155a24d46..a9d608a7e 100644 --- a/kun/src/adapters/model/compat-request-codecs.ts +++ b/kun/src/adapters/model/compat-request-codecs.ts @@ -6,6 +6,7 @@ import { isDeepSeekHost, isGeminiOpenAiHost } from './model-error-probe.js' export const COMPAT_HISTORY_CONTEXT = Symbol('compat-history-context') export const COMPAT_ANTHROPIC_THINKING = Symbol('compat-anthropic-thinking') +export const COMPAT_TOOL_RESULT_ERROR = Symbol('compat-tool-result-error') export type CompatChatMessage = { role: 'system' | 'user' | 'assistant' | 'tool' @@ -14,6 +15,7 @@ export type CompatChatMessage = { [COMPAT_ANTHROPIC_THINKING]?: NonNullable< NonNullable['thinkingBlocks'] > + [COMPAT_TOOL_RESULT_ERROR]?: boolean name?: string tool_call_id?: string reasoning_content?: string diff --git a/kun/src/adapters/model/compat-tool-error-projection.test.ts b/kun/src/adapters/model/compat-tool-error-projection.test.ts new file mode 100644 index 000000000..cdd74b1e0 --- /dev/null +++ b/kun/src/adapters/model/compat-tool-error-projection.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import { makeAssistantTextItem, makeToolCallItem, makeToolResultItem } from '../../domain/item.js' +import type { ModelRequest } from '../../ports/model-client.js' +import { projectCompatMessages } from './compat-message-projector.js' +import { createCompatRequestCodecs } from './compat-request-builder.js' +import { COMPAT_TOOL_RESULT_ERROR } from './compat-request-codecs.js' + +const requestBase: ModelRequest = { + threadId: 'thread', + turnId: 'turn', + model: 'test-model', + prefix: [], + history: [], + tools: [], + abortSignal: new AbortController().signal +} + +function errorMessages(): ReturnType { + return projectCompatMessages({ + ...requestBase, + history: [ + makeAssistantTextItem({ + id: 'assistant', + threadId: 'thread', + turnId: 'turn', + text: 'I will inspect that.' + }), + makeToolCallItem({ + id: 'call-item', + threadId: 'thread', + turnId: 'turn', + callId: 'call-1', + toolName: 'read', + arguments: {}, + status: 'completed' + }), + makeToolResultItem({ + id: 'result-item', + threadId: 'thread', + turnId: 'turn', + callId: 'call-1', + toolName: 'read', + output: { + code: 'tool_cancelled_by_user', + guidance: 'Only this tool was stopped. Do not repeat the identical call automatically.' + }, + isError: true + }) + ] + }, { + thinkingMode: false, + supportsImages: false + }) +} + +describe('tool cancellation protocol projection', () => { + it('keeps the provider-neutral error marker paired with the call id', () => { + const messages = errorMessages() + const tool = messages.find((message) => message.role === 'tool') + expect(tool).toMatchObject({ role: 'tool', tool_call_id: 'call-1' }) + expect(tool?.[COMPAT_TOOL_RESULT_ERROR]).toBe(true) + }) + + it('emits Anthropic is_error and structured text for OpenAI protocols', () => { + const messages = errorMessages() + const codecs = createCompatRequestCodecs() + const common = { + request: requestBase, + model: 'test-model', + messages, + tools: [], + stream: false, + baseUrl: 'https://provider.example/v1', + isCodex: false, + isCodexLite: false, + codexNativeImageGeneration: false + } + + const anthropic = codecs.build({ ...common, endpointFormat: 'messages' }) + const anthropicTool = (anthropic.messages as Array<{ content: unknown }>).flatMap((message) => + Array.isArray(message.content) ? message.content : [] + ).find((block) => (block as { type?: string }).type === 'tool_result') as { + tool_use_id: string + is_error?: boolean + } + expect(anthropicTool).toMatchObject({ tool_use_id: 'call-1', is_error: true }) + + const chat = codecs.build({ ...common, endpointFormat: 'chat_completions' }) + expect(chat.messages).toEqual(expect.arrayContaining([ + expect.objectContaining({ role: 'tool', tool_call_id: 'call-1' }) + ])) + expect(JSON.stringify(chat.messages)).toContain('tool_cancelled_by_user') + + const responses = codecs.build({ ...common, endpointFormat: 'responses' }) + expect(responses.input).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'function_call_output', call_id: 'call-1' }) + ])) + expect(JSON.stringify(responses.input)).toContain('tool_cancelled_by_user') + }) +}) diff --git a/kun/src/adapters/tool/capability-registry.test.ts b/kun/src/adapters/tool/capability-registry.test.ts index e6f02bfa0..de8aa7109 100644 --- a/kun/src/adapters/tool/capability-registry.test.ts +++ b/kun/src/adapters/tool/capability-registry.test.ts @@ -78,6 +78,13 @@ describe('CapabilityRegistry Graph orchestration policy', () => { enabled: true, available: true, tools: [tool('delegate_task'), tool('list_subagent_profiles', 'read-only')] + }, + { + id: 'explore-agent', + kind: 'delegation' as const, + enabled: true, + available: true, + tools: [tool('explore_agent', 'read-only')] } ] @@ -90,8 +97,10 @@ describe('CapabilityRegistry Graph orchestration policy', () => { expect(registry.listTools(current).map((spec) => spec.name)).toEqual([ 'read', 'graph_create_run', - 'graph_control_run' + 'graph_control_run', + 'explore_agent' ]) + expect(registry.resolveTool('explore_agent', current).provider.id).toBe('explore-agent') for (const name of [ 'delegate_task', 'list_subagent_profiles', @@ -115,7 +124,8 @@ describe('CapabilityRegistry Graph orchestration policy', () => { 'graph_create_run', 'graph_control_run', 'delegate_task', - 'list_subagent_profiles' + 'list_subagent_profiles', + 'explore_agent' ]) expect(registry.resolveTool('delegate_task', direct).provider.kind).toBe('delegation') expect(registry.resolveTool('task_graph', direct).provider.id).toBe('builtin') @@ -138,4 +148,31 @@ describe('CapabilityRegistry Plan mode policy', () => { expect(() => registry.resolveTool('mcp_test_mutate', planContext)) .toThrow('tool mcp_test_mutate is not advertised by active tool policy') }) + + it('keeps read-only explore_agent visible in plan mode while hiding delegate_task', () => { + const registry = new CapabilityRegistry([ + { + id: 'delegation', + kind: 'delegation', + enabled: true, + available: true, + tools: [tool('delegate_task'), tool('list_subagent_profiles', 'read-only')] + }, + { + id: 'explore-agent', + kind: 'delegation', + enabled: true, + available: true, + tools: [tool('explore_agent', 'read-only')] + } + ]) + const planContext = context([], 'plan') + + expect(registry.listTools(planContext).map((spec) => spec.name)).toEqual([ + 'list_subagent_profiles', + 'explore_agent' + ]) + expect(() => registry.resolveTool('delegate_task', planContext)) + .toThrow('tool delegate_task is not advertised by active tool policy') + }) }) diff --git a/kun/src/adapters/tool/explore-agent-tool-provider.ts b/kun/src/adapters/tool/explore-agent-tool-provider.ts index 20fe07753..a1350a742 100644 --- a/kun/src/adapters/tool/explore-agent-tool-provider.ts +++ b/kun/src/adapters/tool/explore-agent-tool-provider.ts @@ -4,7 +4,7 @@ import { ModelReasoningEffort, type SubagentProfileConfig } from '../../contracts/capabilities.js' -import type { ToolHostContext } from '../../ports/tool-host.js' +import type { ToolExecutionUpdate, ToolHostContext } from '../../ports/tool-host.js' import type { CapabilityToolProvider } from './capability-registry.js' import { LocalToolHost } from './local-tool-host.js' @@ -43,20 +43,31 @@ const EXPLORE_AGENT_PROMPT_PREAMBLE = [ '绝不修改任何文件或外部状态,也不要执行会改动工作区的命令。' ].join('') +const EXPLORE_AGENT_DESCRIPTION = [ + 'Use this first for any repository or project exploration: locating files or symbols, searching code or keywords, tracing call paths or dependencies, understanding architecture or behavior, or gathering context before a change.', + 'Complex questions MUST be split into multiple parallel explore_agent calls with non-overlapping scopes (for example one call for API wiring, another for UI, another for tests). Never pack a whole-repo investigation into a single call.', + 'Each call needs a short distinct title (2-6 words) for the UI plus a narrow, self-contained query that states what evidence to return.', + '即使后续需要修改文件,也必须先调用 explore_agent;它优先于主代理直接使用 read/grep/glob/ls/repo_map/find/bash,并应为独立调查面并行发起多个调用。', + 'Only use direct inspection tools for narrow follow-up verification after this tool returns, or when explore_agent is unavailable or fails.', + '它可以运行 bash 与只读探索工具(read/grep/glob/ls/repo_map/find/web_fetch/web_search),但始终不会修改文件。' +].join(' ') + /** * First-class `explore_agent` tool: the main agent delegates a scoped * exploration query to a read-oriented child that may use full bash plus the * exploration allow-list. It reuses the whole subagent runtime (child thread, * events, approval inheritance, SubagentCallCard rendering) while keeping the - * delegate_task router untouched. Disabled via Lab settings removes the tool - * from the main agent's tool list entirely. + * delegate_task router untouched. Lab disable is enforced live via + * `shouldAdvertise` (and an execute backstop) so hot-applied settings can + * hide or restore the tool without rebuilding the provider away. */ export function buildExploreAgentToolProvider( runtime: DelegationRuntime | undefined, config: () => ExploreAgentToolConfig | undefined ): CapabilityToolProvider[] { if (!runtime?.enabled()) return [] - if (config()?.enabled === false) return [] + const shouldAdvertise = (_context: ToolHostContext): boolean => + config()?.enabled !== false return [ { id: EXPLORE_AGENT_PROVIDER_ID, @@ -66,43 +77,48 @@ export function buildExploreAgentToolProvider( tools: [ LocalToolHost.defineTool({ name: EXPLORE_AGENT_TOOL_NAME, - description: [ - 'First-class exploration agent for file lookup, code/keyword search, and project information.', - '使用探索代理查找文件、搜索关键字或回答关于项目的问题;优先于 delegate_task 用于纯探索任务。', - '它可以运行 bash 与只读探索工具(read/grep/glob/ls/repo_map/find/web_fetch/web_search),但不会修改文件。' - ].join(' '), + description: EXPLORE_AGENT_DESCRIPTION, inputSchema: { type: 'object', properties: { + title: { + type: 'string', + description: 'Distinct 2-6 word UI title for this exploration (shown in the parallel explore list).' + }, query: { type: 'string', - description: '探索目标:要查找的文件/符号/关键字,或要回答的项目问题。' + description: 'Narrow, self-contained investigation request: what to locate or explain, and which file:line evidence or concise conclusion to return. Do not restate the whole user question if multiple explores are running in parallel.' }, workspace: { type: 'string', description: 'Optional workspace root to explore. Defaults to the parent turn workspace.' } }, - required: ['query'], + required: ['title', 'query'], additionalProperties: false }, policy: 'auto', - execute: async (args, context) => { + sideEffect: 'read-only', + shouldAdvertise, + execute: async (args, context, onUpdate) => { const cfg = config() - if (!cfg || cfg.enabled === false) { + if (cfg?.enabled === false) { return { output: { error: 'explore_agent is disabled in Lab settings' }, isError: true } } + const title = stringValue(args.title) const query = stringValue(args.query) + if (!title) return { output: { error: 'title is required' }, isError: true } if (!query) return { output: { error: 'query is required' }, isError: true } const workspace = stringValue(args.workspace) || context.workspace - const inlineProfile = buildExploreInlineProfile(cfg) + const resolvedCfg = cfg ?? {} + const inlineProfile = buildExploreInlineProfile(resolvedCfg) const record = await runtime.runChild({ parentThreadId: context.threadId, parentTurnId: context.turnId, - label: '探索项目', + label: title, prompt: query, workspace, inlineProfile, @@ -110,7 +126,7 @@ export function buildExploreAgentToolProvider( // Follow the parent session's model/provider/reasoning/service // tier unless the Lab settings configure an explicit override. inheritSessionDefaults: true, - ...(cfg.fast === true ? { serviceTier: 'priority' as const } : {}), + ...(resolvedCfg.fast === true ? { serviceTier: 'priority' as const } : {}), ...(context.serviceTier ? { inheritedServiceTier: context.serviceTier } : {}), ...(context.actingModelRoute?.model ? { inheritedModel: context.actingModelRoute.model } @@ -164,14 +180,63 @@ export function buildExploreAgentToolProvider( approvalReviewer: context.approvalReviewer ?? 'user', ...(context.clientSurface ? { clientSurface: context.clientSurface } : {}), returnFormat: 'summary', + onQueued: async (childId, profile, metadata) => { + await emitExploreLifecycle(onUpdate, { + childId, + status: 'queued', + title, + profile, + metadata: { + ...metadata, + profileName: metadata?.profileName?.trim() || 'Repository Explorer', + model: metadata?.model?.trim() || + context.actingModelRoute?.model?.trim() || + context.model?.id?.trim() || + undefined + } + }) + }, + onRunning: async (childId, profile, metadata) => { + await emitExploreLifecycle(onUpdate, { + childId, + status: 'running', + title, + profile, + metadata: { + ...metadata, + profileName: metadata?.profileName?.trim() || 'Repository Explorer', + model: metadata?.model?.trim() || + context.actingModelRoute?.model?.trim() || + context.model?.id?.trim() || + undefined + } + }) + }, signal: context.abortSignal }) const failed = record.status === 'failed' || record.status === 'aborted' + const resolvedModel = + record.model?.trim() || + (typeof context.actingModelRoute?.model === 'string' + ? context.actingModelRoute.model.trim() + : '') || + context.model?.id?.trim() || + '' + const profileName = + record.profileSnapshot?.name?.trim() || + 'Repository Explorer' return { output: { + childId: record.id, + status: record.status, + title, summary: record.summary ?? '', toolInvocations: record.toolInvocations ?? 0, usage: record.usage, + profile: 'explore', + profileName, + ...(resolvedModel ? { model: resolvedModel } : {}), + ...(record.durationMs !== undefined ? { durationMs: record.durationMs } : {}), ...(failed ? { error: record.error ?? record.status } : {}) }, isError: failed @@ -183,6 +248,30 @@ export function buildExploreAgentToolProvider( ] } +async function emitExploreLifecycle( + onUpdate: ((update: ToolExecutionUpdate) => Promise | void) | undefined, + args: { + childId: string + status: 'queued' | 'running' + title: string + profile?: string + metadata?: { profileName?: string; model?: string; reasoningEffort?: string } + } +): Promise { + await onUpdate?.({ + output: { + childId: args.childId, + status: args.status, + title: args.title, + profile: args.profile ?? 'explore', + profileName: args.metadata?.profileName?.trim() || 'Repository Explorer', + ...(args.metadata?.model ? { model: args.metadata.model } : {}), + ...(args.metadata?.reasoningEffort ? { reasoningEffort: args.metadata.reasoningEffort } : {}) + }, + isError: false + }) +} + function buildExploreInlineProfile( cfg: ExploreAgentToolConfig ): { id: string; profile: SubagentProfileConfig; source: 'builtin' } { diff --git a/kun/src/adapters/tool/explore-agent-tool.test.ts b/kun/src/adapters/tool/explore-agent-tool.test.ts index 3c43ffcd9..afdd98964 100644 --- a/kun/src/adapters/tool/explore-agent-tool.test.ts +++ b/kun/src/adapters/tool/explore-agent-tool.test.ts @@ -25,6 +25,8 @@ import { EXPLORE_AGENT_TOOL_NAME, buildExploreAgentToolProvider } from './explore-agent-tool-provider.js' +import { CapabilityRegistry } from './capability-registry.js' +import { LocalToolHost } from './local-tool-host.js' function makeRuntime(dir: string, executor: ChildRunExecutor): DelegationRuntime { const nowIso = () => '2026-07-08T00:00:00.000Z' @@ -92,22 +94,65 @@ describe('explore_agent tool provider', () => { if (dir) await rm(dir, { recursive: true, force: true }) }) - it('registers the tool only when enabled by default or explicitly', async () => { + it('registers the tool and gates advertising from live Lab settings', async () => { dir = await mkdtemp(join(tmpdir(), 'explore-agent-tool-')) const runtime = makeRuntime(dir, async () => ({ summary: 'ok' })) expect(buildExploreAgentToolProvider(runtime, () => undefined)).toHaveLength(1) expect(buildExploreAgentToolProvider(runtime, () => ({ enabled: true }))).toHaveLength(1) - expect(buildExploreAgentToolProvider(runtime, () => ({ enabled: false }))).toHaveLength(0) + const disabledProvider = buildExploreAgentToolProvider(runtime, () => ({ enabled: false })) + expect(disabledProvider).toHaveLength(1) + expect(disabledProvider[0].tools[0].shouldAdvertise?.(baseContext)).toBe(false) const provider = buildExploreAgentToolProvider(runtime, () => ({}))[0] expect(provider.id).toBe(EXPLORE_AGENT_PROVIDER_ID) expect(provider.tools[0].name).toBe(EXPLORE_AGENT_TOOL_NAME) + expect(provider.tools[0].sideEffect).toBe('read-only') + expect(provider.tools[0].shouldAdvertise?.(baseContext)).toBe(true) + expect(provider.tools[0].description).toContain('Use this first for any repository or project exploration') + expect(provider.tools[0].description).toContain('multiple parallel explore_agent calls') + expect(provider.tools[0].description).toContain('即使后续需要修改文件,也必须先调用 explore_agent') + expect(provider.tools[0].description).toContain('Only use direct inspection tools for narrow follow-up verification') + expect(provider.tools[0].description).toContain('始终不会修改文件') + expect(provider.tools[0].inputSchema).toMatchObject({ + required: ['title', 'query'] + }) + + let cfg: { enabled?: boolean } | undefined + const liveTool = buildExploreAgentToolProvider(runtime, () => cfg)[0].tools[0] + expect(liveTool.shouldAdvertise?.(baseContext)).toBe(true) + cfg = { enabled: false } + expect(liveTool.shouldAdvertise?.(baseContext)).toBe(false) + cfg = { enabled: true } + expect(liveTool.shouldAdvertise?.(baseContext)).toBe(true) }) it('does not register when delegation is unavailable', () => { expect(buildExploreAgentToolProvider(undefined, () => ({ enabled: true }))).toHaveLength(0) }) - it('rejects a missing query and a disabled feature without creating a child run', async () => { + it('stays advertised in plan and graph contexts while Lab is enabled', async () => { + dir = await mkdtemp(join(tmpdir(), 'explore-agent-tool-')) + const runtime = makeRuntime(dir, async () => ({ summary: 'ok' })) + const host = new LocalToolHost({ + registry: new CapabilityRegistry(buildExploreAgentToolProvider(runtime, () => ({ enabled: true }))) + }) + + for (const current of [ + { ...baseContext, threadMode: 'plan' as const }, + { ...baseContext, orchestration: 'graph' as const }, + { ...baseContext, messageSource: 'graph_runtime' as const } + ]) { + const tools = await host.listTools(current) + expect(tools.map((tool) => tool.name)).toEqual([EXPLORE_AGENT_TOOL_NAME]) + expect(tools[0]?.sideEffect).toBe('read-only') + } + + const disabledHost = new LocalToolHost({ + registry: new CapabilityRegistry(buildExploreAgentToolProvider(runtime, () => ({ enabled: false }))) + }) + expect(await disabledHost.listTools({ ...baseContext, threadMode: 'plan' })).toEqual([]) + }) + + it('rejects a missing title/query and a disabled feature without creating a child run', async () => { dir = await mkdtemp(join(tmpdir(), 'explore-agent-tool-')) let ran = false const runtime = makeRuntime(dir, async () => { @@ -115,20 +160,23 @@ describe('explore_agent tool provider', () => { return { summary: 'ok' } }) const tool = buildExploreAgentToolProvider(runtime, () => ({ enabled: true }))[0].tools[0] - const missing = await tool.execute({}, baseContext) - expect(missing.isError).toBe(true) - expect((missing.output as { error: string }).error).toBe('query is required') + const missingBoth = await tool.execute({}, baseContext) + expect(missingBoth.isError).toBe(true) + expect((missingBoth.output as { error: string }).error).toBe('title is required') expect(ran).toBe(false) - // A provider built while disabled registers no tool at all. - expect(buildExploreAgentToolProvider(runtime, () => ({ enabled: false }))).toHaveLength(0) + const missingQuery = await tool.execute({ title: 'Find main' }, baseContext) + expect(missingQuery.isError).toBe(true) + expect((missingQuery.output as { error: string }).error).toBe('query is required') + expect(ran).toBe(false) // The execute-time backstop fires when the feature is turned off after // the tool was already advertised (in-flight call safety). let cfg = { enabled: true } const mutableTool = buildExploreAgentToolProvider(runtime, () => cfg)[0].tools[0] cfg = { enabled: false } - const disabled = await mutableTool.execute({ query: 'find x' }, baseContext) + expect(mutableTool.shouldAdvertise?.(baseContext)).toBe(false) + const disabled = await mutableTool.execute({ title: 'Find x', query: 'find x' }, baseContext) expect(disabled.isError).toBe(true) expect((disabled.output as { error: string }).error).toContain('disabled in Lab settings') expect(ran).toBe(false) @@ -137,6 +185,7 @@ describe('explore_agent tool provider', () => { it('runs a read-oriented child that inherits the main session and returns a summary', async () => { dir = await mkdtemp(join(tmpdir(), 'explore-agent-tool-')) let received: Record | undefined + const lifecycle: Array> = [] const runtime = makeRuntime(dir, async () => ({ summary: 'found src/main.ts:12', toolInvocations: 3 })) const originalRunChild = runtime.runChild.bind(runtime) runtime.runChild = (async (input) => { @@ -144,18 +193,38 @@ describe('explore_agent tool provider', () => { return originalRunChild(input) }) as typeof runtime.runChild const tool = buildExploreAgentToolProvider(runtime, () => ({ enabled: true }))[0].tools[0] - const result = await tool.execute({ query: 'where is main defined' }, baseContext) + const result = await tool.execute( + { title: 'Locate main symbol', query: 'where is main defined' }, + baseContext, + async (update) => { + lifecycle.push(update.output as Record) + } + ) expect(result.isError).toBeFalsy() expect(result.output).toMatchObject({ summary: 'found src/main.ts:12', - toolInvocations: 3 + toolInvocations: 3, + title: 'Locate main symbol', + profile: 'explore', + profileName: 'Repository Explorer', + model: 'main-model', + status: 'completed' + }) + expect(typeof (result.output as { childId?: string }).childId).toBe('string') + expect((result.output as { childId: string }).childId.length).toBeGreaterThan(0) + expect(lifecycle.length).toBeGreaterThanOrEqual(1) + expect(lifecycle[0]).toMatchObject({ + status: expect.stringMatching(/^(queued|running)$/), + title: 'Locate main symbol', + profile: 'explore' }) + expect(typeof lifecycle[0]?.childId).toBe('string') expect(received).toMatchObject({ parentThreadId: 'thr_main', parentTurnId: 'turn_main', prompt: 'where is main defined', workspace: '/workspace', - label: '探索项目', + label: 'Locate main symbol', agentSurface: 'code', inheritSessionDefaults: true, inheritedModel: 'main-model', @@ -192,7 +261,7 @@ describe('explore_agent tool provider', () => { reasoningEffort: 'medium', fast: true }))[0].tools[0] - await tool.execute({ query: 'inspect' }, baseContext) + await tool.execute({ title: 'Inspect module', query: 'inspect' }, baseContext) const inline = received?.inlineProfile as { profile: Record } expect(inline.profile.model).toBe('gpt-5.4') expect(inline.profile.providerId).toBe('codex-2') diff --git a/kun/src/contracts/items.ts b/kun/src/contracts/items.ts index e95db8ed5..2249eb72d 100644 --- a/kun/src/contracts/items.ts +++ b/kun/src/contracts/items.ts @@ -122,6 +122,8 @@ export const ToolCallTurnItem = TurnItemBase.extend({ kind: z.literal('tool_call'), toolName: z.string().min(1), callId: z.string().min(1), + /** Set when a user requested cancellation of this still-running call. */ + cancelRequestedAt: z.string().optional(), toolKind: z.enum(['tool_call', 'command_execution', 'file_change']), arguments: z.record(z.string(), z.unknown()), /** diff --git a/kun/src/contracts/threads.ts b/kun/src/contracts/threads.ts index 049be8f0f..07978e68b 100644 --- a/kun/src/contracts/threads.ts +++ b/kun/src/contracts/threads.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { TurnSchema } from './turns.js' +import { TurnSchema, TurnStatus } from './turns.js' import { ApprovalPolicySchema, ApprovalReviewerSchema, @@ -12,6 +12,24 @@ import { export const ThreadStatus = z.enum(['idle', 'running', 'archived', 'deleted']) export type ThreadStatus = z.infer +/** + * Small runtime-facing projection for background status checks. Unlike the + * full thread document this deliberately excludes turn items/history, making + * it safe to poll while another conversation is selected. + */ +export const ThreadRuntimeStateSchema = z.object({ + id: z.string().min(1), + status: ThreadStatus, + updatedAt: z.string(), + latestSeq: z.number().int().nonnegative(), + latestTurn: z.object({ + id: z.string().min(1), + status: TurnStatus, + orchestration: z.enum(['direct', 'graph']) + }).nullable() +}) +export type ThreadRuntimeState = z.infer + /** * The generic thread PATCH endpoint only owns the archival visibility * overlay. Execution and deletion states are controlled by TurnService and diff --git a/kun/src/contracts/turns.ts b/kun/src/contracts/turns.ts index f52c201d5..0ec5ab1ce 100644 --- a/kun/src/contracts/turns.ts +++ b/kun/src/contracts/turns.ts @@ -358,6 +358,14 @@ export const InterruptTurnResponse = z.object({ }) export type InterruptTurnResponse = z.infer +export const CancelToolCallResponse = z.object({ + threadId: z.string().min(1), + turnId: z.string().min(1), + callId: z.string().min(1), + status: z.enum(['cancellation_requested', 'already_requested']) +}).strict() +export type CancelToolCallResponse = z.infer + export const CompactRequest = z.object({ reason: z.string().optional(), /** Optional explicit token budget. */ diff --git a/kun/src/contracts/usage.ts b/kun/src/contracts/usage.ts index 1e047ba53..89d45630d 100644 --- a/kun/src/contracts/usage.ts +++ b/kun/src/contracts/usage.ts @@ -51,7 +51,25 @@ export const UsageSnapshotSchema = z.object({ tokenEconomySavingsUsd: z.number().nonnegative().optional(), tokenEconomySavingsCny: z.number().nonnegative().optional(), /** Provider reported an unrecoverable error mid-stream. */ - hasError: z.boolean().optional() + hasError: z.boolean().optional(), + /** + * Time-to-first-token of this single model request (ms), measured from + * request start until the first text/reasoning chunk arrives. Missing for + * non-streaming or legacy providers. + */ + requestTtftMs: z.number().nonnegative().optional(), + /** Time spent generating this single model response (ms), from first chunk + * until the final usage/completed chunk. Used with `completionTokens` to + * derive per-request tokens-per-second. */ + requestGenerationMs: z.number().nonnegative().optional(), + /** Average TTFT across model calls of the current turn (null = no data). */ + turnAvgTtftMs: z.number().nonnegative().nullable().optional(), + /** Average tokens-per-second across model calls of the current turn. */ + turnAvgTokensPerSecond: z.number().nonnegative().nullable().optional(), + /** Thread-cumulative average TTFT across all model calls (null = no data). */ + avgTtftMs: z.number().nonnegative().nullable().optional(), + /** Thread-cumulative average tokens-per-second across all model calls. */ + avgTokensPerSecond: z.number().nonnegative().nullable().optional() }) export type UsageSnapshot = z.infer @@ -158,5 +176,9 @@ export const emptyUsageSnapshot = (): UsageSnapshot => ({ cacheMissTokens: 0, cacheHitRate: null, turns: 0, - tokenEconomySavingsTokens: 0 + tokenEconomySavingsTokens: 0, + turnAvgTtftMs: null, + turnAvgTokensPerSecond: null, + avgTtftMs: null, + avgTokensPerSecond: null }) diff --git a/kun/src/delegation/child-agent-executor.ts b/kun/src/delegation/child-agent-executor.ts index f3c0b7b5d..813baf1b2 100644 --- a/kun/src/delegation/child-agent-executor.ts +++ b/kun/src/delegation/child-agent-executor.ts @@ -286,7 +286,10 @@ export function createChildAgentExecutor(options: ChildAgentExecutorOptions): Ch // providerId into every ModelRequest, and the executor's model is the // MultiProviderModelClient, so this single field is all routing needs. ...(input.providerId ? { providerId: input.providerId } : {}), - ...(input.accountId ? { accountId: input.accountId } : {}) + ...(input.accountId ? { accountId: input.accountId } : {}), + // Persist the resolved profile id so the GUI can label explore/side + // sessions (e.g. return-bar "viewing explore process"). + ...(input.profile?.trim() ? { agentId: input.profile.trim() } : {}) }, { id: input.childId, title, diff --git a/kun/src/graph/graph-run-completion.ts b/kun/src/graph/graph-run-completion.ts index 6bf5c606f..365b66fce 100644 --- a/kun/src/graph/graph-run-completion.ts +++ b/kun/src/graph/graph-run-completion.ts @@ -59,18 +59,44 @@ export async function finishGraphRun(initialRun: GraphRunV1, options: Completion } /** - * Summary persistence is the first durable step of terminal finalization. - * A later cleanup or status-transition failure may temporarily move the run - * back to supervision. Only summaries whose current revision still satisfies - * the normal completion gates receive that protection. + * Node/completion gates have passed (or the run is already in completing). + * Used only to avoid cancelling semantically finished work on incidental Lead + * settlement. Does not by itself authorize silent finalization. + */ +export function isGraphRunSemanticComplete(run: GraphRunV1): boolean { + return run.status === 'completing' || graphRunCompletionGatesPassed(run) +} + +/** + * Unresolved holds that make auto-finish unsafe even when gates have passed: + * human attention, scheduler errors, or any needs_attention obligation. + */ +export function graphRunHasBlockingFinalizationHold(run: GraphRunV1): boolean { + if (run.status === 'awaiting_human') return true + return run.supervisionObligations.some((obligation) => + obligation.state !== 'resolved' && + ( + obligation.state === 'needs_attention' || + obligation.kind === 'scheduler_error' + )) +} + +/** + * Finalization is safe to push through the scheduler without another Lead + * episode. Distinct from {@link isGraphRunSemanticComplete}: + * - semantic complete: do not cancel on incidental settlement + * - finalization safe: resumeRun may complete without bypassing blockers + * + * Requirements: completing, or (gates passed + no mailbox blockers + no + * human/scheduler finalization holds). Missing summary is allowed so the + * gates-passed race before tryComplete can finish after resumeRun. */ export function isGraphRunCompletionFinalizing( run: GraphRunV1, mailbox: Pick ): boolean { - return run.status === 'completing' || ( - run.summary !== undefined && - graphRunCompletionGatesPassed(run) && - mailbox.unresolvedBlockers(run).length === 0 - ) + if (run.status === 'completing') return true + if (mailbox.unresolvedBlockers(run).length > 0) return false + if (graphRunHasBlockingFinalizationHold(run)) return false + return graphRunCompletionGatesPassed(run) } diff --git a/kun/src/graph/graph-tool-boundary.test.ts b/kun/src/graph/graph-tool-boundary.test.ts index bfc79f7f9..7b7b7d387 100644 --- a/kun/src/graph/graph-tool-boundary.test.ts +++ b/kun/src/graph/graph-tool-boundary.test.ts @@ -34,6 +34,19 @@ describe('Graph tool boundary', () => { }, { orchestration: 'direct' })).toBe(true) }) + it('keeps Lab explore_agent available on Graph Lead turns', () => { + expect(isToolAllowedInOrchestration({ + toolName: 'explore_agent', + providerId: 'explore-agent', + providerKind: 'delegation' + }, { orchestration: 'graph' })).toBe(true) + expect(isToolAllowedInOrchestration({ + toolName: 'explore_agent', + providerId: 'explore-agent', + providerKind: 'delegation' + }, { messageSource: 'graph_runtime' })).toBe(true) + }) + it('builds executor authority without ordinary or Graph orchestration tools', () => { const names = graphParentAuthorityToolNames([ 'read', @@ -43,7 +56,8 @@ describe('Graph tool boundary', () => { 'delegate_task', 'list_subagent_profiles', 'task_graph', - 'design_component' + 'design_component', + 'explore_agent' ]) expect(names).toEqual(['read']) diff --git a/kun/src/graph/graph-tool-boundary.ts b/kun/src/graph/graph-tool-boundary.ts index 21f3c5e84..4a9bea4e4 100644 --- a/kun/src/graph/graph-tool-boundary.ts +++ b/kun/src/graph/graph-tool-boundary.ts @@ -1,5 +1,9 @@ import type { ToolHostContext, ToolProviderKind } from '../ports/tool-host.js' +/** Keep as literals to avoid a cycle with explore-agent-tool-provider → capability-registry. */ +const EXPLORE_AGENT_TOOL_NAME = 'explore_agent' +const EXPLORE_AGENT_PROVIDER_ID = 'explore-agent' + export const GRAPH_LEAD_TOOL_NAMES = [ 'graph_define_plan', 'graph_create_run', @@ -23,6 +27,8 @@ export const GRAPH_WORKER_REPORT_TOOL_NAME = 'report_to_parent' as const * Ordinary orchestration surfaces conflict with host-owned Graph scheduling. * Provider-kind filtering covers current and future delegation tools; exact * names cover legacy DAG state and built-in wrappers that can spawn a child. + * Lab `explore_agent` is exempt from Lead listing (read-only investigation) + * but is still stripped from Worker assignment snapshots below. */ export const GRAPH_INCOMPATIBLE_TOOL_NAMES = [ 'delegate_task', @@ -44,6 +50,14 @@ export function isGraphLeadContext( context?.messageSource === 'graph_runtime' } +function isExploreAgentTool(input: { + toolName: string + providerId: string +}): boolean { + return input.toolName === EXPLORE_AGENT_TOOL_NAME || + input.providerId === EXPLORE_AGENT_PROVIDER_ID +} + export function isToolAllowedInOrchestration( input: { toolName: string @@ -53,6 +67,9 @@ export function isToolAllowedInOrchestration( context: Pick | undefined ): boolean { if (!isGraphLeadContext(context)) return true + // Read-only Lab explore stays available on Graph Lead turns so planning can + // gather repository facts without ordinary delegate_task / child fan-out. + if (isExploreAgentTool(input)) return true if (input.providerKind === 'delegation' || input.providerId === 'delegation') { return false } @@ -66,6 +83,7 @@ export function isToolAllowedInOrchestration( */ export function graphParentAuthorityToolNames(toolNames: readonly string[]): string[] { return [...new Set(toolNames.filter((name) => + name !== EXPLORE_AGENT_TOOL_NAME && !INCOMPATIBLE_TOOL_NAMES.has(name) && !LEAD_TOOL_NAMES.has(name) && !WORKER_TOOL_NAMES.has(name) && diff --git a/kun/src/loop/agent-loop.ts b/kun/src/loop/agent-loop.ts index 2c43cb378..9d3695b10 100644 --- a/kun/src/loop/agent-loop.ts +++ b/kun/src/loop/agent-loop.ts @@ -34,6 +34,7 @@ import type { import { ContextCompactor } from './context-compactor.js' import type { RolesConfig } from '../config/kun-config.js' import { InflightTracker } from './inflight-tracker.js' +import { ToolCancellationRegistry } from './tool-cancellation-registry.js' import { SteeringQueue } from './steering-queue.js' import { createImmutablePrefix @@ -141,6 +142,7 @@ export type AgentLoopOptions = { events: RuntimeEventRecorder turns: TurnService inflight: InflightTracker + toolCancellation?: ToolCancellationRegistry steering: SteeringQueue compactor: ContextCompactor prefix: ImmutablePrefix @@ -289,7 +291,7 @@ export class AgentLoop { constructor(opts: AgentLoopOptions) { this.opts = opts - this.telemetry = new LoopTelemetry(opts.sessionStore) + this.telemetry = new LoopTelemetry() this.threadItems = new ThreadItemProjectionService({ threadStore: opts.threadStore, sessionStore: opts.sessionStore, @@ -362,6 +364,7 @@ export class AgentLoop { this.toolExecution = new ToolExecutionService({ toolHost: opts.toolHost, inflight: opts.inflight, + toolCancellation: opts.toolCancellation, turns: opts.turns, events: opts.events, nowIso: opts.nowIso, diff --git a/kun/src/loop/compaction-history.test.ts b/kun/src/loop/compaction-history.test.ts index 6bd823ea0..d5c8eee1c 100644 --- a/kun/src/loop/compaction-history.test.ts +++ b/kun/src/loop/compaction-history.test.ts @@ -57,6 +57,7 @@ describe('compaction history projection', () => { expect(visible.map((item) => item.id)).toEqual([ 'item_head_a', 'item_head_b', + 'compaction_previous', 'compaction_next', 'item_tail_a', 'item_tail_b' @@ -116,7 +117,7 @@ describe('compaction history projection', () => { ]) }) - it('preserves manual compaction markers when coalescing automatic markers', () => { + it('preserves manual and automatic compaction markers with distinct identities', () => { const threadId = 'thread_1' const turnId = 'turn_1' const manualSummary = makeCompactionItem({ @@ -238,7 +239,7 @@ describe('compaction history projection', () => { ]) }) - it('coalesces old automatic markers while preserving manual compactions', () => { + it('preserves distinct automatic and manual markers in chronological order', () => { const threadId = 'thread_1' const turnId = 'turn_coalesce' const userMessage = { @@ -291,6 +292,7 @@ describe('compaction history projection', () => { ]).map((item) => item.id) ).toEqual([ 'item_user_coalesce', + 'compaction_old_auto', 'compaction_manual', 'compaction_latest_auto' ]) diff --git a/kun/src/loop/compaction-history.ts b/kun/src/loop/compaction-history.ts index 916940381..bcd120459 100644 --- a/kun/src/loop/compaction-history.ts +++ b/kun/src/loop/compaction-history.ts @@ -17,10 +17,7 @@ export function insertCompactionIntoVisibleHistory(input: { }): TurnItem[] { const summaryIndex = input.compactedItems.findIndex((item) => item.id === input.summaryItem.id) if (summaryIndex < 0) { - return replaceOrAppendItem( - coalesceAutomaticCompactions(input.visibleItems, input.summaryItem), - input.summaryItem - ) + return replaceOrAppendItem(input.visibleItems, input.summaryItem) } // Goal context is internal model history. `ContextCompactor` intentionally @@ -38,10 +35,9 @@ export function insertCompactionIntoVisibleHistory(input: { .filter((item) => item.kind !== 'goal_context') .map((item) => item.id) ) - const withoutSummary = coalesceAutomaticCompactions( - input.visibleItems, - input.summaryItem - ).filter((item) => item.id !== input.summaryItem.id && item.kind !== 'goal_context') + const withoutSummary = input.visibleItems.filter( + (item) => item.id !== input.summaryItem.id && item.kind !== 'goal_context' + ) if (tailIds.size === 0) return [...withoutSummary, input.summaryItem, ...goalContexts] const insertIndex = withoutSummary.findIndex((item) => tailIds.has(item.id)) @@ -83,10 +79,9 @@ function replaceOrAppendItem(items: readonly TurnItem[], item: TurnItem): TurnIt * items move here; every other item keeps its established relative order. */ export function placeCompactionsChronologically(items: readonly TurnItem[]): TurnItem[] { - const coalesced = coalesceAutomaticCompactions(items) - const indexed = coalesced.map((item, sourceIndex) => ({ item, sourceIndex })) + const indexed = items.map((item, sourceIndex) => ({ item, sourceIndex })) const compactions = indexed.filter(({ item }) => isVisibleCompaction(item)) - if (compactions.length === 0) return coalesced + if (compactions.length === 0) return [...items] const timeline = indexed.filter(({ item }) => !isVisibleCompaction(item)) const turnOwnerItemIds = new Map() @@ -159,22 +154,3 @@ function timelineEntryFollowsCompaction( function isVisibleCompaction(item: TurnItem): boolean { return item.kind === 'compaction' && item.replacedTokens > 0 } - -/** Keep manual markers and only the newest automatic marker for each turn. */ -function coalesceAutomaticCompactions( - items: readonly TurnItem[], - incoming?: TurnItem -): TurnItem[] { - const latestAutoByTurn = new Map() - for (const item of [...items, ...(incoming ? [incoming] : [])]) { - if (isAutomaticCompaction(item)) latestAutoByTurn.set(item.turnId, item.id) - } - if (latestAutoByTurn.size === 0) return [...items] - return items.filter((item) => - !isAutomaticCompaction(item) || latestAutoByTurn.get(item.turnId) === item.id - ) -} - -function isAutomaticCompaction(item: TurnItem): boolean { - return item.kind === 'compaction' && item.replacedTokens > 0 && item.auto !== false -} diff --git a/kun/src/loop/history-compaction-service.test.ts b/kun/src/loop/history-compaction-service.test.ts index 56a0bb5ba..8306b085c 100644 --- a/kun/src/loop/history-compaction-service.test.ts +++ b/kun/src/loop/history-compaction-service.test.ts @@ -64,7 +64,6 @@ function modelCompactionService( events: createEvents(sessionStore), ids: new SequentialIdGenerator(), telemetry: { - hydratePromptPressureIfCold: async () => undefined, consumePromptPressure: () => undefined }, recordGoalUsage: async () => undefined, @@ -93,7 +92,6 @@ describe('HistoryCompactionService', () => { events: createEvents(sessionStore), ids: new SequentialIdGenerator(), telemetry: { - hydratePromptPressureIfCold: async () => undefined, consumePromptPressure: () => undefined }, recordGoalUsage: async () => undefined, @@ -172,7 +170,7 @@ describe('HistoryCompactionService', () => { })).toEqual({ providerId: 'EXT-CURRENT', accountId: 'account-current' }) }) - it('hydrates pressure, atomically writes the visible marker, then projects and reports it', async () => { + it('consumes live pressure, atomically writes the visible marker, then projects and reports it', async () => { const sessionStore = new InMemorySessionStore() for (let index = 0; index < 5; index += 1) { await sessionStore.appendItem(threadId, makeUserItem({ @@ -182,16 +180,9 @@ describe('HistoryCompactionService', () => { text: `older context ${index} ${'x'.repeat(120)}` })) } - const telemetryCalls: string[] = [] const telemetry = { - hydratePromptPressureIfCold: vi.fn(async () => { - telemetryCalls.push('hydrate') - }), - consumePromptPressure: vi.fn(() => { - telemetryCalls.push('consume') - return undefined - }) - } as unknown as Pick + consumePromptPressure: vi.fn(() => undefined) + } as unknown as Pick const effectOrder: string[] = [] const service = new HistoryCompactionService({ sessionStore, @@ -219,7 +210,7 @@ describe('HistoryCompactionService', () => { turnId })).history - expect(telemetryCalls).toEqual(['hydrate', 'consume']) + expect(telemetry.consumePromptPressure).toHaveBeenCalledWith(threadId, 'test-model') expect(history[0]).toMatchObject({ kind: 'compaction', id: 'compaction_1' }) expect(effectOrder).toEqual([`clear:${threadId}`, `project:${threadId}`]) const persisted = await sessionStore.loadItems(threadId) @@ -249,7 +240,6 @@ describe('HistoryCompactionService', () => { events: createEvents(sessionStore), ids: new SequentialIdGenerator(), telemetry: { - hydratePromptPressureIfCold: async () => undefined, consumePromptPressure: () => undefined }, recordGoalUsage: async () => undefined, @@ -279,9 +269,8 @@ describe('HistoryCompactionService', () => { const sessionStore = new InMemorySessionStore() const item = makeUserItem({ id: 'item_only', threadId, turnId, text: 'short' }) const telemetry = { - hydratePromptPressureIfCold: vi.fn(async () => undefined), consumePromptPressure: vi.fn(() => undefined) - } as unknown as Pick + } as unknown as Pick const rewriteThreadItemsFromSession = vi.fn(async () => undefined) const service = new HistoryCompactionService({ sessionStore, @@ -307,7 +296,6 @@ describe('HistoryCompactionService', () => { expect(history).toBe(inputItems) expect(history).toEqual([item]) - expect(telemetry.hydratePromptPressureIfCold).toHaveBeenCalledWith(threadId, 'test-model') expect(telemetry.consumePromptPressure).toHaveBeenCalledWith(threadId, 'test-model') expect(rewriteThreadItemsFromSession).not.toHaveBeenCalled() await expect(sessionStore.loadItems(threadId)).resolves.toEqual([]) @@ -332,7 +320,6 @@ describe('HistoryCompactionService', () => { events: createEvents(sessionStore), ids: new SequentialIdGenerator(), telemetry: { - hydratePromptPressureIfCold: async () => undefined, consumePromptPressure: () => undefined }, recordGoalUsage: async () => undefined, @@ -386,7 +373,6 @@ describe('HistoryCompactionService', () => { events: createEvents(sessionStore), ids: new SequentialIdGenerator(), telemetry: { - hydratePromptPressureIfCold: async () => undefined, consumePromptPressure: () => undefined }, recordGoalUsage: async () => undefined, @@ -497,7 +483,6 @@ describe('HistoryCompactionService', () => { events: createEvents(sessionStore), ids: new SequentialIdGenerator(), telemetry: { - hydratePromptPressureIfCold: async () => undefined, consumePromptPressure: () => undefined }, recordGoalUsage, diff --git a/kun/src/loop/history-compaction-service.ts b/kun/src/loop/history-compaction-service.ts index f2ef69adc..bffe8b630 100644 --- a/kun/src/loop/history-compaction-service.ts +++ b/kun/src/loop/history-compaction-service.ts @@ -33,7 +33,7 @@ export type HistoryCompactionServiceDeps = { usage: UsageService events: RuntimeEventRecorder ids: IdGenerator - telemetry: Pick + telemetry: Pick recordGoalUsage: (threadId: string, tokens: number) => Promise /** Read live runtime config so hot-apply affects future compactions. */ getContextCompaction?: () => ContextCompactionConfig | undefined @@ -97,7 +97,6 @@ export class HistoryCompactionService { allowModelSummary?: boolean reserveModelRequest?: () => Promise<{ allowed: boolean; reason?: string }> }): Promise { - await this.deps.telemetry.hydratePromptPressureIfCold(input.threadId, input.model) const pressure = this.deps.telemetry.consumePromptPressure(input.threadId, input.model) const thresholdModel = pressure?.model || input.model const overheadTokens = input.requestOverheadTokens === undefined diff --git a/kun/src/loop/loop-telemetry.test.ts b/kun/src/loop/loop-telemetry.test.ts index ee65d38f9..5b4258e56 100644 --- a/kun/src/loop/loop-telemetry.test.ts +++ b/kun/src/loop/loop-telemetry.test.ts @@ -1,28 +1,15 @@ -import { describe, expect, it, vi } from 'vitest' -import type { SessionStore } from '../ports/session-store.js' +import { describe, expect, it } from 'vitest' import { LoopTelemetry } from './loop-telemetry.js' describe('LoopTelemetry', () => { - it('hydrates the latest positive persisted prompt pressure only once', async () => { - const loadUsageRecords = vi.fn().mockResolvedValue([ - { threadId: 'thread_1', model: 'older', usage: { promptTokens: 10 } }, - { threadId: 'thread_1', model: '', usage: { promptTokens: 30 } }, - { threadId: 'other', model: 'other', usage: { promptTokens: 100 } } - ]) - const telemetry = new LoopTelemetry({ loadUsageRecords } as unknown as SessionStore) + it('starts without pressure instead of restoring cumulative usage', () => { + const telemetry = new LoopTelemetry() - await telemetry.hydratePromptPressureIfCold('thread_1', 'fallback') - - expect(telemetry.consumePromptPressure('thread_1', 'fallback')).toEqual({ - model: 'fallback', - promptTokens: 30 - }) - await telemetry.hydratePromptPressureIfCold('thread_1', 'fallback') - expect(loadUsageRecords).toHaveBeenCalledTimes(1) + expect(telemetry.consumePromptPressure('thread_1', 'fallback')).toBeUndefined() }) it('keeps the highest prompt pressure seen before compaction consumes it', () => { - const telemetry = new LoopTelemetry({} as unknown as SessionStore) + const telemetry = new LoopTelemetry() telemetry.recordPromptPressure('thread_1', 'first', 20) telemetry.recordPromptPressure('thread_1', 'smaller', 10) @@ -36,7 +23,7 @@ describe('LoopTelemetry', () => { }) it('classifies additive and breaking tool catalog changes without persistence side effects', () => { - const telemetry = new LoopTelemetry({} as unknown as SessionStore) + const telemetry = new LoopTelemetry() const base = { threadId: 'thread_1', workspace: '/workspace', diff --git a/kun/src/loop/loop-telemetry.ts b/kun/src/loop/loop-telemetry.ts index 789979506..7636c1b81 100644 --- a/kun/src/loop/loop-telemetry.ts +++ b/kun/src/loop/loop-telemetry.ts @@ -1,8 +1,6 @@ import type { GuiDesignArtifactContext } from '../ports/tool-host.js' -import type { SessionStore } from '../ports/session-store.js' const MAX_TOOL_CATALOG_SNAPSHOTS = 256 -const MAX_HYDRATED_PRESSURE_THREADS = 512 type ToolCatalogSnapshot = { fingerprint: string @@ -38,12 +36,8 @@ export type ToolCatalogFingerprintInput = { */ export class LoopTelemetry { private readonly promptTokenPressure = new Map() - /** Threads for which a one-time pressure hydration from persisted usage was already attempted. */ - private readonly hydratedPressureThreads = new Set() private readonly toolCatalogSnapshots = new Map() - constructor(private readonly sessionStore: SessionStore) {} - recordPromptPressure(threadId: string, model: string, promptTokens: number): void { if (!threadId || promptTokens <= 0) return const current = this.promptTokenPressure.get(threadId) @@ -51,40 +45,6 @@ export class LoopTelemetry { this.promptTokenPressure.set(threadId, { model, promptTokens }) } - /** - * Seed prompt pressure from persisted request usage once per thread and - * process. This keeps a restart from underestimating a history that already - * includes a large system prompt or tool catalog. Failure is intentionally - * best-effort; the caller's local estimator remains the fallback. - */ - async hydratePromptPressureIfCold(threadId: string, fallbackModel: string): Promise { - if (!threadId) return - if (this.promptTokenPressure.has(threadId)) return - if (this.hydratedPressureThreads.has(threadId)) return - const loadUsageRecords = this.sessionStore.loadUsageRecords - if (typeof loadUsageRecords !== 'function') { - this.rememberHydratedPressureThread(threadId) - return - } - try { - const records = await loadUsageRecords.call(this.sessionStore, { threadId }) - let restored: { model: string; promptTokens: number } | undefined - for (const record of records) { - if (record.threadId !== threadId) continue - const promptTokens = Math.floor(record.usage?.promptTokens ?? 0) - if (promptTokens > 0) { - restored = { model: record.model || fallbackModel, promptTokens } - } - } - if (restored && !this.promptTokenPressure.has(threadId)) { - this.promptTokenPressure.set(threadId, restored) - } - this.rememberHydratedPressureThread(threadId) - } catch { - // Best-effort restore; the estimator + overhead floor still applies. - } - } - consumePromptPressure( threadId: string, model: string @@ -134,14 +94,6 @@ export class LoopTelemetry { : { kind: 'breaking', previous } } - private rememberHydratedPressureThread(threadId: string): void { - this.hydratedPressureThreads.delete(threadId) - this.hydratedPressureThreads.add(threadId) - if (this.hydratedPressureThreads.size > MAX_HYDRATED_PRESSURE_THREADS) { - const oldest = this.hydratedPressureThreads.values().next().value - if (oldest !== undefined) this.hydratedPressureThreads.delete(oldest) - } - } } function isAdditiveToolCatalogChange(previous: ToolCatalogSnapshot, current: ToolCatalogSnapshot): boolean { diff --git a/kun/src/loop/model-request-composer.test.ts b/kun/src/loop/model-request-composer.test.ts index 8fb6b0d95..988381479 100644 --- a/kun/src/loop/model-request-composer.test.ts +++ b/kun/src/loop/model-request-composer.test.ts @@ -15,17 +15,27 @@ const emptyAttachments = { } as const describe('composeModelRequest', () => { - it('clamps declared output to the remaining safe context capacity', () => { + it('bounds model output capability by the ordinary reservation and remaining capacity', () => { expect(effectiveOutputBudgetTokens({ inputTokens: 14_236, contextCapTokens: 111_411, declaredMaxOutputTokens: 128_000 - })).toBe(97_175) + })).toBe(32_768) expect(effectiveOutputBudgetTokens({ inputTokens: 14_236, contextCapTokens: 111_411, declaredMaxOutputTokens: 500_000 - })).toBe(97_175) + })).toBe(32_768) + expect(effectiveOutputBudgetTokens({ + inputTokens: 14_236, + contextCapTokens: 111_411, + declaredMaxOutputTokens: 8_000 + })).toBe(8_000) + expect(effectiveOutputBudgetTokens({ + inputTokens: 105_000, + contextCapTokens: 111_411, + declaredMaxOutputTokens: 128_000 + })).toBe(6_411) }) it('uses a finite fallback when a model has no output metadata', () => { diff --git a/kun/src/loop/model-request-composer.ts b/kun/src/loop/model-request-composer.ts index 51028b701..bd5bd90a1 100644 --- a/kun/src/loop/model-request-composer.ts +++ b/kun/src/loop/model-request-composer.ts @@ -32,7 +32,12 @@ export function effectiveOutputBudgetTokens(input: { ? fallback : Math.max(1, Math.floor(input.declaredMaxOutputTokens)) const remaining = Math.max(1, Math.floor(input.contextCapTokens - input.inputTokens)) - return Math.min(declared, remaining) + // `maxOutputTokens` is provider capability metadata, not an instruction to + // reserve the model's entire maximum on every request. Keep the ordinary + // request reservation bounded by the runtime default; smaller model limits + // remain authoritative, and the final request is still clamped to the + // remaining safe context capacity. + return Math.min(declared, fallback, remaining) } export type ModelRequestComposerInput = Readonly<{ diff --git a/kun/src/loop/model-round-engine.ts b/kun/src/loop/model-round-engine.ts index 9e07a5266..cafc85820 100644 --- a/kun/src/loop/model-round-engine.ts +++ b/kun/src/loop/model-round-engine.ts @@ -361,7 +361,12 @@ export class ModelRoundEngine { input.request.model, intent.usage.promptTokens ) - const usage = this.deps.usage.record(input.threadId, intent.usage, input.cacheSignature) + const usage = this.deps.usage.record( + input.threadId, + intent.usage, + input.cacheSignature, + input.turnId + ) await this.deps.recordGoalUsage(input.threadId, intent.usage.totalTokens) await this.deps.events.record({ kind: 'usage', diff --git a/kun/src/loop/model-step-service.ts b/kun/src/loop/model-step-service.ts index 58ab5a403..8c942e013 100644 --- a/kun/src/loop/model-step-service.ts +++ b/kun/src/loop/model-step-service.ts @@ -709,26 +709,34 @@ export class ModelStepService { signal }).sentInputTokens // Share one capacity model between the compaction preflight and the - // send-time guard. The output budget is reserved for this request's - // completion, so compaction must treat `input + output` as the real - // pressure instead of only comparing input against the soft threshold. + // send-time guard. `maxOutputTokens` is a capability ceiling, so first + // derive the bounded ordinary reservation independently from the current + // input. Final request construction may only lower this preferred value + // when the rebuilt request leaves less room under the hard cap. const declaredOutputBudgetTokens = modelCapabilities.maxOutputTokens const requestHardCapTokens = modelCapabilities.contextWindowTokens ? Math.floor(modelCapabilities.contextWindowTokens * 0.85) : this.deps.compactor.hardCap(model, providerId) - // Use the request overhead as the conservative input floor during compaction; - // the final request is recalculated after history/image rehydration. - const effectiveBudget = (inputTokens: number): number => + const preferredOutputBudgetTokens = modelCapabilities.endpointFormat === 'messages' && declaredOutputBudgetTokens === undefined ? 0 : effectiveOutputBudgetTokens({ - inputTokens, + inputTokens: 0, contextCapTokens: requestHardCapTokens, ...(declaredOutputBudgetTokens !== undefined ? { declaredMaxOutputTokens: declaredOutputBudgetTokens } : {}) }) - let outputBudgetTokens = effectiveBudget(requestOverheadTokens) + const effectiveBudget = (inputTokens: number): number => + preferredOutputBudgetTokens === 0 + ? 0 + : effectiveOutputBudgetTokens({ + inputTokens, + contextCapTokens: requestHardCapTokens, + declaredMaxOutputTokens: preferredOutputBudgetTokens, + fallbackTokens: preferredOutputBudgetTokens + }) + let outputBudgetTokens = preferredOutputBudgetTokens // History compaction retries from the latest canonical snapshot to avoid // losing concurrent writes. That snapshot deliberately retains internal // goal records, including records for goals that later ended or changed. diff --git a/kun/src/loop/model-timing-decorator.test.ts b/kun/src/loop/model-timing-decorator.test.ts new file mode 100644 index 000000000..34cf22233 --- /dev/null +++ b/kun/src/loop/model-timing-decorator.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import type { ModelClient, ModelStreamChunk } from '../ports/model-client.js' +import { emptyUsageSnapshot } from '../contracts/usage.js' +import { withModelTiming } from './model-timing-decorator.js' + +function makeClient(chunks: ModelStreamChunk[], clock: { value: number }): ModelClient { + return { + provider: 'test', + model: 'test-model', + async *stream() { + for (const chunk of chunks) { + // Simulate network/provider latency so the decorator observes + // non-zero TTFT and generation durations. + clock.value += 250 + yield chunk + } + } + } +} + +const usageChunk = (completionTokens = 10): ModelStreamChunk => ({ + kind: 'usage', + usage: { ...emptyUsageSnapshot(), completionTokens, totalTokens: completionTokens } +}) + +async function drain(stream: AsyncIterable): Promise { + const out: ModelStreamChunk[] = [] + for await (const chunk of stream) out.push(chunk) + return out +} + +describe('withModelTiming', () => { + it('attaches TTFT and generation duration to the usage chunk of a text stream', async () => { + const clock = { value: 0 } + const client = withModelTiming(makeClient([ + { kind: 'assistant_text_delta', text: 'a' }, + { kind: 'assistant_text_delta', text: 'b' }, + usageChunk(), + { kind: 'completed', stopReason: 'stop' } + ], clock), { now: () => clock.value }) + + const chunks = await drain(client.stream({ + threadId: 't', turnId: 'turn', model: 'm', prefix: [], history: [], + tools: [], abortSignal: new AbortController().signal + })) + const usage = chunks.find((chunk) => chunk.kind === 'usage') + expect(usage).toBeDefined() + if (usage && usage.kind === 'usage') { + // First text chunk arrived at 250ms; usage at 750ms. + expect(usage.usage.requestTtftMs).toBe(250) + expect(usage.usage.requestGenerationMs).toBe(500) + } + }) + + it('falls back to the first tool chunk for pure tool-call rounds', async () => { + const clock = { value: 0 } + const client = withModelTiming(makeClient([ + { kind: 'tool_call_complete', callId: 'call_1', toolName: 'read', arguments: {} }, + usageChunk(5), + { kind: 'completed', stopReason: 'tool_calls' } + ], clock), { now: () => clock.value }) + + const chunks = await drain(client.stream({ + threadId: 't', turnId: 'turn', model: 'm', prefix: [], history: [], + tools: [], abortSignal: new AbortController().signal + })) + const usage = chunks.find((chunk) => chunk.kind === 'usage') + if (usage && usage.kind === 'usage') { + expect(usage.usage.requestTtftMs).toBe(250) + expect(usage.usage.requestGenerationMs).toBe(250) + } + }) + + it('passes streams without a usage chunk through unchanged', async () => { + const clock = { value: 0 } + const client = withModelTiming(makeClient([ + { kind: 'assistant_text_delta', text: 'hello' }, + { kind: 'completed', stopReason: 'stop' } + ], clock), { now: () => clock.value }) + + const chunks = await drain(client.stream({ + threadId: 't', turnId: 'turn', model: 'm', prefix: [], history: [], + tools: [], abortSignal: new AbortController().signal + })) + expect(chunks).toEqual([ + { kind: 'assistant_text_delta', text: 'hello' }, + { kind: 'completed', stopReason: 'stop' } + ]) + }) + + it('does not time a stream that errors before any content chunk', async () => { + const clock = { value: 0 } + const client = withModelTiming(makeClient([ + { kind: 'error', message: 'boom' } + ], clock), { now: () => clock.value }) + + const chunks = await drain(client.stream({ + threadId: 't', turnId: 'turn', model: 'm', prefix: [], history: [], + tools: [], abortSignal: new AbortController().signal + })) + expect(chunks).toEqual([{ kind: 'error', message: 'boom' }]) + }) + + it('preserves chunk metadata such as route identity', async () => { + const clock = { value: 0 } + const route = { routePoolId: 'p', targetId: 'x', providerId: 'prov', modelId: 'm', requestedModelId: 'alias' } + const client = withModelTiming(makeClient([ + { kind: 'assistant_text_delta', text: 'a' }, + { ...usageChunk(), route } + ], clock), { now: () => clock.value }) + + const chunks = await drain(client.stream({ + threadId: 't', turnId: 'turn', model: 'm', prefix: [], history: [], + tools: [], abortSignal: new AbortController().signal + })) + const usage = chunks.find((chunk) => chunk.kind === 'usage') + expect(usage?.route).toEqual({ routePoolId: 'p', targetId: 'x', providerId: 'prov', modelId: 'm', requestedModelId: 'alias' }) + }) +}) diff --git a/kun/src/loop/model-timing-decorator.ts b/kun/src/loop/model-timing-decorator.ts new file mode 100644 index 000000000..b2c1d9dbc --- /dev/null +++ b/kun/src/loop/model-timing-decorator.ts @@ -0,0 +1,74 @@ +import type { + ModelClient, + ModelRequest, + ModelStreamChunk +} from '../ports/model-client.js' + +/** + * Chunks that represent actual model output (as opposed to transport + * bookkeeping such as retries, usage, completion, or error markers). The + * first such chunk marks the end of time-to-first-token; pure tool-call + * rounds fall back to their first tool chunk so they still get a TTFT. + */ +function isContentChunk(chunk: ModelStreamChunk): boolean { + return ( + chunk.kind === 'assistant_text_delta' || + chunk.kind === 'assistant_reasoning_delta' || + chunk.kind === 'tool_call_delta' || + chunk.kind === 'tool_call_complete' || + chunk.kind === 'image_generation_complete' + ) +} + +/** + * Wraps a `ModelClient` so every streamed response carries per-request + * timing on its `usage` chunk: + * + * - `requestTtftMs`: request start -> first content chunk (TTFT). + * - `requestGenerationMs`: first content chunk -> usage chunk (used with + * `completionTokens` to derive tokens-per-second). + * + * The wrapper never modifies the underlying provider parsing; it only + * clones the usage snapshot to attach timing. Streams without a usage + * chunk (or without any content chunk) pass through unchanged. + */ +export function withModelTiming( + client: ModelClient, + options: { now?: () => number } = {} +): ModelClient { + const now = options.now ?? ((): number => performance.now()) + return { + ...client, + stream(request: ModelRequest): AsyncIterable { + return timedStream(client.stream(request), now) + } + } +} + +async function* timedStream( + stream: AsyncIterable, + now: () => number +): AsyncIterable { + const startedAt = now() + let firstChunkAt: number | null = null + for await (const chunk of stream) { + if (firstChunkAt === null && isContentChunk(chunk)) { + firstChunkAt = now() + } + if (chunk.kind === 'usage' && firstChunkAt !== null) { + const usageAt = now() + const ttftMs = Math.max(0, Math.round(firstChunkAt - startedAt)) + const generationMs = Math.max(0, Math.round(usageAt - firstChunkAt)) + yield { + ...chunk, + usage: { + ...chunk.usage, + requestTtftMs: ttftMs, + requestGenerationMs: generationMs + } + } + continue + } + yield chunk + } +} diff --git a/kun/src/loop/plan-mode.ts b/kun/src/loop/plan-mode.ts index 1bd9fed96..32410236a 100644 --- a/kun/src/loop/plan-mode.ts +++ b/kun/src/loop/plan-mode.ts @@ -12,7 +12,7 @@ import { VERIFY_CHANGES_TOOL_NAME } from '../adapters/tool/builtin-verify-tool.j */ export const PLAN_MODE_INSTRUCTION = [ 'You are in Plan mode.', - 'Investigate the task first using the available read-only tools: prefer `repo_map`, `read`, `grep`, `glob`, and `ls`, and use `git_inspect` for repository status, branches, history, revisions, diffs, and merge-base checks.', + 'Investigate the task first using the available read-only tools: when `explore_agent` is available, prefer it for repository or project exploration (file lookup, code/keyword search, symbol and call-path tracing, architecture or behavior inspection); otherwise prefer `repo_map`, `read`, `grep`, `glob`, and `ls`. Use `git_inspect` for repository status, branches, history, revisions, diffs, and merge-base checks.', 'You may use `write` and `edit` only for Markdown (`.md`) working documents. The host rejects every other file mutation in this mode, including attempts through symlinks.', 'Do NOT run mutating shell commands or invoke tools with unknown/external side effects in this mode.', 'If the request is ambiguous or hinges on a decision only the user can make, ask before planning: prefer the `user_input` tool to ask one concise round of clarifying questions (offer concrete options when there are any), then use the answer to write the plan in the same turn. If that tool is not available, end your turn with the question(s) in prose and wait for the answer. Either way, do NOT call `create_plan` until the ambiguity is resolved — a set of options the user still has to choose between is not a plan.', diff --git a/kun/src/loop/tool-call-dispatcher.test.ts b/kun/src/loop/tool-call-dispatcher.test.ts index 53104b3ac..5b2a8455d 100644 --- a/kun/src/loop/tool-call-dispatcher.test.ts +++ b/kun/src/loop/tool-call-dispatcher.test.ts @@ -97,6 +97,45 @@ describe('ToolCallDispatcher', () => { expect(executed).toEqual(['read', 'grep']) }) + it('continues a parallel batch when one result is a tool-level cancellation', async () => { + const persisted: Array<{ callId: string; isError?: boolean }> = [] + const dispatcher = new ToolCallDispatcher({ + executeSafely: vi.fn(async (input: { call: ToolCallLike }) => { + if (input.call.callId === 'read_1') { + return { + item: makeToolResultItem({ + id: 'item_read_1', + threadId: 'thread_1', + turnId: 'turn_1', + callId: 'read_1', + toolName: input.call.toolName, + output: { code: 'tool_cancelled_by_user' }, + isError: true + }), + approved: false + } + } + return resultFor(input.call) + }), + persistResult: vi.fn(async (_threadId: string, _turnId: string, entry: ToolCallLike, result: ToolHostResult) => { + persisted.push({ + callId: entry.callId, + isError: result.item.kind === 'tool_result' ? result.item.isError : undefined + }) + }), + persistSuppressed: vi.fn(async () => undefined) + } as never) + + await expect(dispatcher.dispatch({ + dispatch: dispatchInput([call('read', 'read_1'), call('grep', 'grep_1')]), + context + })).resolves.toBe('continue') + expect(persisted).toEqual([ + { callId: 'read_1', isError: true }, + { callId: 'grep_1', isError: false } + ]) + }) + it('reports all-suppressed only when no call executes', async () => { const persistSuppressed = vi.fn(async () => undefined) const dispatcher = new ToolCallDispatcher({ diff --git a/kun/src/loop/tool-cancellation-registry.test.ts b/kun/src/loop/tool-cancellation-registry.test.ts new file mode 100644 index 000000000..d64d7fcc8 --- /dev/null +++ b/kun/src/loop/tool-cancellation-registry.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { + ToolCancellationRegistry, + ToolExecutionCancelledError +} from './tool-cancellation-registry.js' + +describe('ToolCancellationRegistry', () => { + it('isolates a child cancellation from the parent and sibling tools', () => { + const registry = new ToolCancellationRegistry() + const parent = new AbortController() + const first = registry.register( + { threadId: 'thread', turnId: 'turn', callId: 'first' }, + parent.signal + ) + const second = registry.register( + { threadId: 'thread', turnId: 'turn', callId: 'second' }, + parent.signal + ) + + expect(registry.request( + { threadId: 'thread', turnId: 'turn', callId: 'first' }, + '2026-08-07T00:00:00.000Z' + )).toBe('cancellation_requested') + expect(first.signal.aborted).toBe(true) + expect(first.wasCancelledByUser()).toBe(true) + expect(second.signal.aborted).toBe(false) + expect(registry.request( + { threadId: 'thread', turnId: 'turn', callId: 'first' }, + '2026-08-07T00:00:01.000Z' + )).toBe('already_requested') + }) + + it('propagates a parent turn abort without treating it as a user tool cancel', () => { + const registry = new ToolCancellationRegistry() + const parent = new AbortController() + const child = registry.register( + { threadId: 'thread', turnId: 'turn', callId: 'call' }, + parent.signal + ) + + parent.abort(new Error('turn interrupted')) + + expect(child.signal.aborted).toBe(true) + expect(child.wasCancelledByUser()).toBe(false) + expect(registry.request( + { threadId: 'thread', turnId: 'turn', callId: 'call' }, + '2026-08-07T00:00:00.000Z' + )).toBe('turn_aborted') + expect(ToolExecutionCancelledError).toBeDefined() + }) + + it('removes handles when a tool settles', () => { + const registry = new ToolCancellationRegistry() + const registration = registry.register( + { threadId: 'thread', turnId: 'turn', callId: 'call' }, + new AbortController().signal + ) + registration.dispose() + expect(registry.has({ threadId: 'thread', turnId: 'turn', callId: 'call' })).toBe(false) + expect(registry.list()).toEqual([]) + }) +}) diff --git a/kun/src/loop/tool-cancellation-registry.ts b/kun/src/loop/tool-cancellation-registry.ts new file mode 100644 index 000000000..b3f204340 --- /dev/null +++ b/kun/src/loop/tool-cancellation-registry.ts @@ -0,0 +1,111 @@ +/** + * Process-local cancellation handles for foreground tool executions. + * + * A turn owns the parent abort signal. Each tool gets a child signal so a + * user can stop one tool while the rest of the model step continues. The + * registry is intentionally independent from AgentLoop instances because + * model/provider hot reload replaces the loop while active executions may + * still belong to the previous instance. + */ + +export const TOOL_CANCELLED_BY_USER_CODE = 'tool_cancelled_by_user' + +export class ToolExecutionCancelledError extends Error { + readonly code = TOOL_CANCELLED_BY_USER_CODE + + constructor() { + super('Tool execution was stopped by the user.') + this.name = 'ToolExecutionCancelledError' + } +} + +export type ToolCancellationKey = { + threadId: string + turnId: string + callId: string +} + +type ActiveToolCancellation = ToolCancellationKey & { + controller: AbortController + requestedAt?: string + detachParent: () => void +} + +export type ToolCancellationRegistration = { + signal: AbortSignal + wasCancelledByUser: () => boolean + dispose: () => void +} + +export type ToolCancellationRequestStatus = + | 'cancellation_requested' + | 'already_requested' + | 'not_found' + | 'turn_aborted' + +function keyFor(input: ToolCancellationKey): string { + return `${input.threadId}\u0000${input.turnId}\u0000${input.callId}` +} + +function isUserCancellationReason(reason: unknown): boolean { + return reason instanceof ToolExecutionCancelledError || + (reason instanceof Error && reason.name === 'ToolExecutionCancelledError') +} + +export class ToolCancellationRegistry { + private readonly active = new Map() + + register( + input: ToolCancellationKey, + parentSignal: AbortSignal + ): ToolCancellationRegistration { + const controller = new AbortController() + const key = keyFor(input) + const onParentAbort = (): void => { + if (!controller.signal.aborted) controller.abort(parentSignal.reason) + } + parentSignal.addEventListener('abort', onParentAbort, { once: true }) + + const entry: ActiveToolCancellation = { + ...input, + controller, + detachParent: () => parentSignal.removeEventListener('abort', onParentAbort) + } + this.active.set(key, entry) + if (parentSignal.aborted) onParentAbort() + + let disposed = false + return { + signal: controller.signal, + wasCancelledByUser: () => isUserCancellationReason(controller.signal.reason), + dispose: () => { + if (disposed) return + disposed = true + entry.detachParent() + if (this.active.get(key) === entry) this.active.delete(key) + } + } + } + + request(input: ToolCancellationKey, requestedAt: string): ToolCancellationRequestStatus { + const entry = this.active.get(keyFor(input)) + if (!entry) return 'not_found' + if (entry.requestedAt) return 'already_requested' + if (entry.controller.signal.aborted) return 'turn_aborted' + entry.requestedAt = requestedAt + entry.controller.abort(new ToolExecutionCancelledError()) + return 'cancellation_requested' + } + + has(input: ToolCancellationKey): boolean { + return this.active.has(keyFor(input)) + } + + list(): ToolCancellationKey[] { + return [...this.active.values()].map(({ threadId, turnId, callId }) => ({ + threadId, + turnId, + callId + })) + } +} diff --git a/kun/src/loop/tool-dispatch-policy.test.ts b/kun/src/loop/tool-dispatch-policy.test.ts index 723590b51..8878ec681 100644 --- a/kun/src/loop/tool-dispatch-policy.test.ts +++ b/kun/src/loop/tool-dispatch-policy.test.ts @@ -38,20 +38,31 @@ describe('tool dispatch policy', () => { expect(classifyToolDispatchLane(call('write'), builtIn)).toBe('serial') }) - it('classifies only delegation-provider delegate_task calls as parallel delegation', () => { - const delegated = policy('auto', { delegate_task: 'delegation' }) + it('classifies delegation-provider delegate_task and explore_agent as parallel delegation', () => { + const delegated = policy('auto', { + delegate_task: 'delegation', + explore_agent: 'delegation' + }) expect(classifyToolDispatchLane(call('delegate_task'), delegated)).toBe('delegation') + expect(classifyToolDispatchLane(call('explore_agent'), delegated)).toBe('delegation') expect(isParallelDelegationCall(call('delegate_task'), delegated)).toBe(true) + expect(isParallelDelegationCall(call('explore_agent'), delegated)).toBe(true) expect(isParallelDelegationCall(call('delegate_task'), policy('auto', { delegate_task: 'built-in' }))).toBe(false) + expect(isParallelDelegationCall(call('explore_agent'), policy('auto', { explore_agent: 'built-in' }))).toBe(false) }) it.each(['always', 'untrusted', 'never'] as const)( 'keeps %s policy calls serial', (approvalPolicy) => { - const current = policy(approvalPolicy, { read: 'built-in', delegate_task: 'delegation' }) + const current = policy(approvalPolicy, { + read: 'built-in', + delegate_task: 'delegation', + explore_agent: 'delegation' + }) expect(classifyToolDispatchLane(call('read'), current)).toBe('serial') expect(classifyToolDispatchLane(call('delegate_task'), current)).toBe('serial') + expect(classifyToolDispatchLane(call('explore_agent'), current)).toBe('serial') expect(isParallelSafeToolCall(call('read'), current)).toBe(false) } ) @@ -85,4 +96,38 @@ describe('tool dispatch policy', () => { expect(collectParallelToolDispatchCandidates({ calls: [call('write')], startIndex: 0, policy: current })).toBeNull() expect(collectParallelToolDispatchCandidates({ calls, startIndex: 9, policy: current })).toBeNull() }) + + it('batches contiguous explore_agent calls on the delegation lane', () => { + const current = policy('auto', { + explore_agent: 'delegation', + delegate_task: 'delegation', + read: 'built-in' + }) + const calls = [ + call('explore_agent', 'explore_1'), + call('explore_agent', 'explore_2'), + call('explore_agent', 'explore_3'), + call('read', 'read_1') + ] + + expect(collectParallelToolDispatchCandidates({ calls, startIndex: 0, policy: current })) + .toEqual({ lane: 'delegation', calls: calls.slice(0, 3) }) + expect(collectParallelToolDispatchCandidates({ calls, startIndex: 3, policy: current })) + .toEqual({ lane: 'read_only', calls: calls.slice(3) }) + }) + + it('batches mixed contiguous delegate_task and explore_agent on the same delegation lane', () => { + const current = policy('auto', { + explore_agent: 'delegation', + delegate_task: 'delegation' + }) + const calls = [ + call('explore_agent', 'explore_1'), + call('delegate_task', 'delegate_1'), + call('explore_agent', 'explore_2') + ] + + expect(collectParallelToolDispatchCandidates({ calls, startIndex: 0, policy: current })) + .toEqual({ lane: 'delegation', calls }) + }) }) diff --git a/kun/src/loop/tool-dispatch-policy.ts b/kun/src/loop/tool-dispatch-policy.ts index 0bc75760a..af73329e3 100644 --- a/kun/src/loop/tool-dispatch-policy.ts +++ b/kun/src/loop/tool-dispatch-policy.ts @@ -3,6 +3,11 @@ import type { ToolCallLike, ToolProviderKind } from '../ports/tool-host.js' const PARALLEL_READ_ONLY_TOOL_NAMES = new Set(['read', 'grep', 'glob', 'find', 'ls']) const DELEGATE_TASK_TOOL_NAME = 'delegate_task' +const EXPLORE_AGENT_TOOL_NAME = 'explore_agent' +const PARALLEL_DELEGATION_TOOL_NAMES = new Set([ + DELEGATE_TASK_TOOL_NAME, + EXPLORE_AGENT_TOOL_NAME +]) export const DEFAULT_MAX_PARALLEL_READ_ONLY_TOOL_CALLS = 3 export type ToolDispatchLane = 'serial' | 'read_only' | 'delegation' @@ -55,7 +60,7 @@ export function isParallelDelegationCall( call: ToolCallLike, policy: Pick ): boolean { - return call.toolName === DELEGATE_TASK_TOOL_NAME && + return PARALLEL_DELEGATION_TOOL_NAMES.has(call.toolName) && policy.toolProviderKinds.get(call.toolName) === 'delegation' } diff --git a/kun/src/loop/tool-execution-service.test.ts b/kun/src/loop/tool-execution-service.test.ts index df8972f27..452f47f6e 100644 --- a/kun/src/loop/tool-execution-service.test.ts +++ b/kun/src/loop/tool-execution-service.test.ts @@ -5,6 +5,7 @@ import type { ToolHost, ToolHostContext, ToolHostResult } from '../ports/tool-ho import type { RuntimeEventRecorder } from '../services/runtime-event-recorder.js' import type { TurnService } from '../services/turn-service.js' import { InflightTracker } from './inflight-tracker.js' +import { ToolCancellationRegistry } from './tool-cancellation-registry.js' import { ToolExecutionService } from './tool-execution-service.js' const call = { @@ -27,6 +28,7 @@ function makeService(input: { execute?: ToolHost['execute'] onPlanWritten?: () => Promise awaitWorkspaceCheckpoint?: (requestId: string, signal: AbortSignal) => Promise + toolCancellation?: ToolCancellationRegistry } = {}) { const lifecycle: string[] = [] const events: Array> = [] @@ -57,7 +59,8 @@ function makeService(input: { ...(input.awaitWorkspaceCheckpoint ? { awaitWorkspaceCheckpoint: input.awaitWorkspaceCheckpoint } : {}), - ...(input.onPlanWritten ? { onPlanWritten: input.onPlanWritten } : {}) + ...(input.onPlanWritten ? { onPlanWritten: input.onPlanWritten } : {}), + ...(input.toolCancellation ? { toolCancellation: input.toolCancellation } : {}) }) return { service, lifecycle, events, turns } } @@ -82,6 +85,84 @@ describe('ToolExecutionService', () => { ])) }) + it('turns an accepted tool cancellation into a paired model-visible error result', async () => { + const registry = new ToolCancellationRegistry() + let started!: () => void + const toolStarted = new Promise((resolve) => { started = resolve }) + const setup = makeService({ + toolCancellation: registry, + execute: async (_call, executionContext) => { + started() + return await new Promise((_resolve, reject) => { + executionContext.abortSignal.addEventListener('abort', () => { + reject(executionContext.abortSignal.reason) + }, { once: true }) + }) + } + }) + const parent = new AbortController() + const execution = setup.service.executeSafely({ + threadId: 'thread_1', + turnId: 'turn_1', + call, + context: { ...context, abortSignal: parent.signal } + }) + await toolStarted + expect(registry.request( + { threadId: 'thread_1', turnId: 'turn_1', callId: 'call_1' }, + '2026-08-07T00:00:00.000Z' + )).toBe('cancellation_requested') + const result = await execution + expect(result).toMatchObject({ approved: false, item: { isError: true } }) + expect(result.item.kind === 'tool_result' ? result.item.output : null).toMatchObject({ + code: 'tool_cancelled_by_user', + guidance: expect.stringContaining('Do not repeat the identical call automatically') + }) + expect(registry.list()).toEqual([]) + }) + + it('keeps the cancellation result when a tool catches abort and returns normally', async () => { + const registry = new ToolCancellationRegistry() + let started!: () => void + const toolStarted = new Promise((resolve) => { started = resolve }) + const setup = makeService({ + toolCancellation: registry, + execute: async (toolCall, executionContext) => { + started() + await new Promise((resolve) => { + executionContext.abortSignal.addEventListener('abort', () => resolve(), { once: true }) + }) + return { + item: makeToolResultItem({ + id: `item_${toolCall.callId}`, + threadId: 'thread_1', + turnId: 'turn_1', + callId: toolCall.callId, + toolName: toolCall.toolName, + output: { stale: true } + }), + approved: true + } + } + }) + const execution = setup.service.executeSafely({ + threadId: 'thread_1', + turnId: 'turn_1', + call, + context: { ...context, abortSignal: new AbortController().signal } + }) + await toolStarted + expect(registry.request( + { threadId: 'thread_1', turnId: 'turn_1', callId: 'call_1' }, + '2026-08-07T00:00:00.000Z' + )).toBe('cancellation_requested') + const result = await execution + expect(result.item).toMatchObject({ kind: 'tool_result', isError: true }) + expect(result.item.kind === 'tool_result' ? result.item.output : null).toMatchObject({ + code: 'tool_cancelled_by_user' + }) + }) + it('waits for a pending checkpoint before the first workspace mutation', async () => { const order: string[] = [] const setup = makeService({ diff --git a/kun/src/loop/tool-execution-service.ts b/kun/src/loop/tool-execution-service.ts index 15f6f5671..79a3dc8e2 100644 --- a/kun/src/loop/tool-execution-service.ts +++ b/kun/src/loop/tool-execution-service.ts @@ -5,6 +5,10 @@ import type { ToolCallLike, ToolHost, ToolHostContext, ToolHostResult } from '.. import type { RuntimeEventRecorder } from '../services/runtime-event-recorder.js' import type { TurnService } from '../services/turn-service.js' import { InflightTracker } from './inflight-tracker.js' +import { + TOOL_CANCELLED_BY_USER_CODE, + ToolCancellationRegistry +} from './tool-cancellation-registry.js' import { prepareBrowserUseToolResultForPersistence } from './tool-result-image.js' export type PlanWrittenCallback = (input: { @@ -20,6 +24,7 @@ export type ToolExecutionServiceDeps = { inflight: InflightTracker turns: TurnService events: RuntimeEventRecorder + toolCancellation?: ToolCancellationRegistry nowIso: () => string onPlanWritten?: PlanWrittenCallback awaitWorkspaceCheckpoint?: ( @@ -42,14 +47,47 @@ export type ToolExecutionInput = { */ export class ToolExecutionService { private readonly checkpointGates = new Map>() + private readonly deps: ToolExecutionServiceDeps + private readonly toolCancellation: ToolCancellationRegistry - constructor(private readonly deps: ToolExecutionServiceDeps) {} + constructor(deps: ToolExecutionServiceDeps) { + const toolCancellation = deps.toolCancellation ?? new ToolCancellationRegistry() + this.deps = { + ...deps, + toolCancellation + } + this.toolCancellation = toolCancellation + } async executeSafely(input: ToolExecutionInput): Promise { + // Detached/background turns keep their dedicated lifecycle controls. The + // registry is intentionally limited to foreground GUI tool calls. + const registration = input.context.messageSource + ? undefined + : this.toolCancellation.register( + { + threadId: input.threadId, + turnId: input.turnId, + callId: input.call.callId + }, + input.context.abortSignal + ) + const executionInput: ToolExecutionInput = { + ...input, + ...(registration + ? { context: { ...input.context, abortSignal: registration.signal } } + : {}) + } try { - return await this.execute(input) + const result = await this.execute(executionInput) + if (input.context.abortSignal.aborted) { + throw input.context.abortSignal.reason ?? new Error('Tool execution aborted') + } + if (registration?.wasCancelledByUser()) return this.cancelledResult(input) + return result } catch (error) { - if (input.context.abortSignal.aborted) throw error + if (input.context.abortSignal.aborted && !registration?.wasCancelledByUser()) throw error + if (registration?.wasCancelledByUser()) return this.cancelledResult(input) const message = error instanceof Error ? error.message : String(error) await this.deps.events.record({ kind: 'error', @@ -77,6 +115,29 @@ export class ToolExecutionService { }), approved: false } + } finally { + registration?.dispose() + } + } + + private cancelledResult(input: ToolExecutionInput): ToolHostResult { + return { + item: makeToolResultItem({ + id: `item_${input.call.callId}`, + turnId: input.turnId, + threadId: input.threadId, + callId: input.call.callId, + toolName: input.call.toolName, + toolKind: input.call.toolKind ?? 'tool_call', + output: { + code: TOOL_CANCELLED_BY_USER_CODE, + error: 'The user stopped this tool execution.', + guidance: + 'Only this tool was stopped. Continue using the other tool results and choose an alternative approach. Do not repeat the identical call automatically.' + }, + isError: true + }), + approved: false } } diff --git a/kun/src/prompt/graph-lead-mode.test.ts b/kun/src/prompt/graph-lead-mode.test.ts index 23519ec5a..4618251a6 100644 --- a/kun/src/prompt/graph-lead-mode.test.ts +++ b/kun/src/prompt/graph-lead-mode.test.ts @@ -78,6 +78,17 @@ describe('Graph Lead mode system contract', () => { ) }) + it('allows read-only explore_agent while forbidding ordinary delegate_task in planning', () => { + expect(GRAPH_LEAD_MODE_INSTRUCTION).toContain('Prefer `explore_agent` when it is advertised') + expect(GRAPH_LEAD_MODE_INSTRUCTION).toContain('or use ordinary `delegate_task` during planning') + expect(GRAPH_LEAD_MODE_INSTRUCTION).toContain( + 'Do not use ordinary `delegate_task` / reusable-profile delegation' + ) + expect(GRAPH_LEAD_MODE_INSTRUCTION).toContain( + 'Read-only `explore_agent` remains allowed for repository investigation' + ) + }) + it('keeps executors task-only and makes every handoff a Lead decision', () => { expect(GRAPH_LEAD_MODE_INSTRUCTION).toContain( 'They can proactively use `report_to_parent`' diff --git a/kun/src/prompt/graph-lead-mode.ts b/kun/src/prompt/graph-lead-mode.ts index 9e92598fb..78bd795e7 100644 --- a/kun/src/prompt/graph-lead-mode.ts +++ b/kun/src/prompt/graph-lead-mode.ts @@ -32,7 +32,7 @@ Do not treat dispatch or one milestone as completion. The host has already created a durable planning draft for this turn. Follow these five steps: -1. Inspect the relevant project facts with the available read-only tools before defining work. Do not mutate files, run arbitrary commands, or delegate during planning. +1. Inspect the relevant project facts with the available read-only tools before defining work. Prefer \`explore_agent\` when it is advertised; do not mutate files, run arbitrary commands, or use ordinary \`delegate_task\` during planning. 2. Split the outcome into focused tasks. Use \`dependsOn\` only for real control ordering and \`dataFrom\` only when a task consumes a named accepted predecessor result. 3. Call \`graph_define_plan\` using only its advertised fields. A complete minimal valid call is: \`{"plan":{"title":"Update project documentation","tasks":[{"key":"update_docs","kind":"work","title":"Update the documentation","objective":"Inspect the current documentation and make the requested corrections.","dependsOn":[],"dataFrom":[],"acceptanceCriteria":["The requested behavior is documented with a concrete example."],"readScopes":["."],"writeScopes":["docs"]}],"completionTaskKeys":["update_docs"]}}\` @@ -45,7 +45,7 @@ The host has already created a durable planning draft for this turn. Follow thes - Maximize useful safe fan-out, not node count for its own sake. Split independent concerns, subsystems, repository regions, or validation tracks into sibling ready nodes so the scheduler can use the available concurrency. Keep nodes large enough to produce a meaningful reviewed result; do not create line-by-line busywork. - Treat independence as the default. Add a control edge only when the successor truly requires the predecessor outcome, and add a data edge only when it consumes that accepted named result packet. Do not serialize nodes merely because they belong to the same phase or because their final results will later be integrated. If the work is inherently sequential, keep the real dependency. - A data-edge name labels the bounded result packet you will approve for the successor; it does not require the executor to publish an artifact. Avoid worker-to-worker message flow. Use explicit completion nodes and only bounded LoopGates. A LoopGate may observe only a source node that has produced a real outcome; never route repair or final work from a pending condition source. Ordinary dependencies must remain acyclic. -- Do not use ordinary delegation, legacy task_graph fields, guessed profile ids, assignments, or host-owned identity/provenance fields while planning. +- Do not use ordinary \`delegate_task\` / reusable-profile delegation, legacy task_graph fields, guessed profile ids, assignments, or host-owned identity/provenance fields while planning. Read-only \`explore_agent\` remains allowed for repository investigation. - Never submit budget, model, provider, reasoning, timeout, retry, priority, phase, revision, workspace, run-id, or timestamp fields. They belong to the host and are intentionally absent from \`graph_define_plan\`. - Scopes must be normalized repository-relative paths such as \`.\`, \`src\`, or \`.graph-artifacts\`, never absolute workspace paths. - Ordinary \`work\`, \`review\`, and \`integration\` tasks never contain \`loop\`. Only a \`loop_gate\` task contains the required bounded loop object. diff --git a/kun/src/prompt/kun-system-prompt.test.ts b/kun/src/prompt/kun-system-prompt.test.ts index 93b03246e..06927c6a2 100644 --- a/kun/src/prompt/kun-system-prompt.test.ts +++ b/kun/src/prompt/kun-system-prompt.test.ts @@ -160,6 +160,44 @@ describe('buildToolPreferenceInstruction', () => { expect(buildToolPreferenceInstruction([...tools].reverse())).toBe(instruction) }) + it('makes explore_agent the first step for all repository investigation', () => { + const tools = [ + { name: 'explore_agent', description: 'Explore the repository' }, + { name: 'read', description: 'Read a file' }, + { name: 'grep', description: 'Search file contents' }, + { name: 'bash', description: 'Run a shell command' }, + { name: 'edit', description: 'Edit a file' }, + { + name: 'mcp_symbol_graph', + description: 'Navigate source definitions and reference call graph', + providerKind: 'mcp' + } + ] + const instruction = buildToolPreferenceInstruction(tools) + + expect(instruction).toContain('Use `explore_agent` as the first tool') + expect(instruction).toContain('This applies even to simple lookups and to tasks that will later modify files') + expect(instruction).toContain('Only after `explore_agent` returns') + expect(instruction).toContain('narrow follow-up') + expect(instruction).toContain('parent agent remains responsible for edits') + expect(instruction).toContain('Issue multiple `explore_agent` calls together') + expect(instruction).not.toContain('do not use it for tasks that require write access') + expect(instruction).not.toContain('Prefer `read` over `bash`') + expect(buildToolPreferenceInstruction([...tools].reverse())).toBe(instruction) + }) + + it('keeps direct inspection guidance when explore_agent is unavailable', () => { + const instruction = buildToolPreferenceInstruction([ + { name: 'read', description: 'Read a file' }, + { name: 'grep', description: 'Search file contents' }, + { name: 'bash', description: 'Run a shell command' } + ]) + + expect(instruction).toContain('Inspect relevant current state before changing it') + expect(instruction).toContain('Prefer `read`, `grep` over `bash`') + expect(instruction).not.toContain('explore_agent') + }) + it('adds bounded delegation guidance only when the child-agent tool is available', () => { const instruction = buildToolPreferenceInstruction([ { name: 'delegate_task', description: 'Run a standalone child agent' } diff --git a/kun/src/prompt/kun-system-prompt.ts b/kun/src/prompt/kun-system-prompt.ts index a452e2466..3f6eb30f8 100644 --- a/kun/src/prompt/kun-system-prompt.ts +++ b/kun/src/prompt/kun-system-prompt.ts @@ -60,7 +60,7 @@ type ToolPreferenceSpec = { const SOURCE_EXPLORATION_PATTERN = /\b(?:code(?:base|graph)?|source|repository|repo|symbol|definition|reference|implementation|dependency|call[ -]?graph|ast)\b/i -const INSPECTION_TOOL_NAMES = ['read', 'grep', 'glob', 'ls', 'repo_map', 'lsp'] as const +const INSPECTION_TOOL_NAMES = ['read', 'grep', 'glob', 'ls', 'repo_map', 'find', 'lsp'] as const const MUTATION_TOOL_NAMES = ['edit', 'write'] as const const TODO_TOOL_NAMES = ['todo_list', 'todo_write'] as const const GOAL_TOOL_NAMES = ['get_goal', 'create_goal', 'update_goal'] as const @@ -83,9 +83,10 @@ export function buildToolPreferenceInstruction( const goalTools = presentNames(names, GOAL_TOOL_NAMES) const inputTools = presentNames(names, USER_INPUT_TOOL_NAMES) const memoryTools = presentNames(names, MEMORY_TOOL_NAMES) + const exploreAgentAvailable = names.has('explore_agent') const bullets: string[] = [] - if (inspectionTools.length > 0) { + if (inspectionTools.length > 0 && !exploreAgentAvailable) { bullets.push( `Inspect relevant current state before changing it. Use ${formatToolNames(inspectionTools)} for the matching file, search, directory, repository, or symbol operation.` ) @@ -97,16 +98,20 @@ export function buildToolPreferenceInstruction( bullets.push( 'Run independent inspection calls in parallel when their inputs do not depend on one another; keep dependent work sequential.' ) - } else if (names.has('bash')) { + } else if (names.has('bash') && !exploreAgentAvailable) { bullets.push('Use `bash` for necessary shell and system operations, with commands scoped to the active workspace and task.') } if (mutationTools.length > 0) { if (names.has('edit')) { - bullets.push('Use `edit` for focused changes to existing files after reading the relevant content.') + bullets.push(exploreAgentAvailable + ? 'After `explore_agent` returns, use `edit` for focused changes to existing files; the parent agent owns all mutations.' + : 'Use `edit` for focused changes to existing files after reading the relevant content.') } if (names.has('write')) { - bullets.push('Use `write` only when creating or fully replacing a file is necessary; do not create files for explanation or one-off scratch work in the project.') + bullets.push(exploreAgentAvailable + ? 'After `explore_agent` returns, use `write` only when creating or fully replacing a file is necessary; do not create files for explanation or one-off scratch work in the project.' + : 'Use `write` only when creating or fully replacing a file is necessary; do not create files for explanation or one-off scratch work in the project.') } if (names.has('bash')) { bullets.push( @@ -168,18 +173,37 @@ export function buildToolPreferenceInstruction( ) } - if (names.has('explore_agent')) { + if (exploreAgentAvailable) { + const directInspectionTools = [ + ...inspectionTools, + ...(names.has('bash') ? ['bash'] : []) + ] bullets.push( - 'Use `explore_agent` for file lookup, code/keyword search, and project information exploration: it runs a dedicated read-oriented child with bash plus exploration tools and returns a file:line summary. Prefer it over `delegate_task` for pure investigation; keep `delegate_task` for broader units of work that need full tool access.' + 'Use `explore_agent` as the first tool for any repository or project exploration: file lookup, code or keyword search, symbol and call-path tracing, architecture or behavior inspection, and context gathering before a change. This applies even to simple lookups and to tasks that will later modify files.' ) bullets.push( - '`explore_agent` never edits files — do not use it for tasks that require write access.' + '`explore_agent` runs a dedicated read-oriented child and never edits files; after it returns, the parent agent remains responsible for edits and final verification.' ) + if (directInspectionTools.length > 0) { + bullets.push( + `Only after \`explore_agent\` returns, or when it is unavailable or fails, use ${formatToolNames(directInspectionTools)} for narrow follow-up verification and unsupported-file fallback.` + ) + } + bullets.push( + 'Issue multiple `explore_agent` calls together when the exploration questions are independent; keep dependent investigation sequential.' + ) + if (names.has('delegate_task')) { + bullets.push( + 'Reserve `delegate_task` for broader child work that needs full tool access; use `explore_agent` for repository investigation.' + ) + } } if (names.has('graph_define_plan')) { bullets.push( - 'A durable Graph planning draft already exists. Inspect relevant repository facts with read-only tools, then use `graph_define_plan` with only task keys, objectives, dependencies, acceptance criteria, and repository-relative scopes. The host supplies every execution mechanic.' + exploreAgentAvailable + ? 'A durable Graph planning draft already exists. Use `explore_agent` to inspect relevant repository facts first, then use `graph_define_plan` with only task keys, objectives, dependencies, acceptance criteria, and repository-relative scopes. The host supplies every execution mechanic.' + : 'A durable Graph planning draft already exists. Inspect relevant repository facts with read-only tools, then use `graph_define_plan` with only task keys, objectives, dependencies, acceptance criteria, and repository-relative scopes. The host supplies every execution mechanic.' ) bullets.push( 'You may make one changed correction from structured validation issues. Never repeat unchanged invalid plan arguments or claim a GraphRun exists before `graph_define_plan` returns committed.' @@ -208,15 +232,17 @@ export function buildToolPreferenceInstruction( const fallback = inspectionTools.length > 0 ? ` Use ${formatToolNames(inspectionTools)} for unsupported files, narrow fallback checks, and verification.` : '' - bullets.push( - `Specialized source-code MCP tools are available: ${formatToolNames(sourceTools.map((tool) => tool.name))}. Prefer a matching one for structural source navigation before broad scans.${fallback}` - ) + bullets.push(exploreAgentAvailable + ? `Specialized source-code MCP tools are available: ${formatToolNames(sourceTools.map((tool) => tool.name))}. Start repository exploration with \`explore_agent\`; use a matching MCP tool only for narrow structural follow-up or when exploration fails.${fallback}` + : `Specialized source-code MCP tools are available: ${formatToolNames(sourceTools.map((tool) => tool.name))}. Prefer a matching one for structural source navigation before broad scans.${fallback}`) } else if (mcpTools.some((tool) => tool.name === 'mcp_search')) { - bullets.push('Use `mcp_search` when the task may benefit from a specialized external capability not already advertised.') + bullets.push(exploreAgentAvailable + ? 'Start repository exploration with `explore_agent`; use `mcp_search` only for a specialized external capability that the exploration child cannot provide.' + : 'Use `mcp_search` when the task may benefit from a specialized external capability not already advertised.') } else if (mcpTools.length > 0) { - bullets.push( - `Use an advertised MCP tool when its description directly matches the task: ${formatToolNames(mcpTools.map((tool) => tool.name))}.` - ) + bullets.push(exploreAgentAvailable + ? `Start repository exploration with \`explore_agent\`; use an advertised MCP tool only when its description directly matches a narrow follow-up task: ${formatToolNames(mcpTools.map((tool) => tool.name))}.` + : `Use an advertised MCP tool when its description directly matches the task: ${formatToolNames(mcpTools.map((tool) => tool.name))}.`) } if (bullets.length === 0) return null diff --git a/kun/src/runtime/cursor/cursor-sdk-runtime-factory.test.ts b/kun/src/runtime/cursor/cursor-sdk-runtime-factory.test.ts index 62b3fd662..3dea9de6e 100644 --- a/kun/src/runtime/cursor/cursor-sdk-runtime-factory.test.ts +++ b/kun/src/runtime/cursor/cursor-sdk-runtime-factory.test.ts @@ -167,16 +167,15 @@ describe('Cursor SDK runtime factory', () => { const planning = await loadKunTurnContext(input) expect(planning.graphPhase).toBe('planning') + // Overlapping Cursor built-ins (read/write) are not bridged as custom tools. expect(planning.tools.map((tool) => tool.name).sort()).toEqual([ - 'graph_define_plan', - 'read' + 'graph_define_plan' ]) expect(planning.instructionBlocks.join('\n')).toContain( 'Graph Mode: source Lead operating contract' ) expect(Object.keys(planning.customTools).sort()).toEqual([ - 'graph_define_plan', - 'read' + 'graph_define_plan' ]) expect(planning.graphPlanWasCommitted?.()).toBe(false) expect(planning.graphPlanCanRetry?.()).toBe(true) @@ -195,8 +194,7 @@ describe('Cursor SDK runtime factory', () => { const supervising = await loadKunTurnContext(input) expect(supervising.graphPhase).toBe('supervising') expect(supervising.tools.map((tool) => tool.name).sort()).toEqual([ - 'graph_review_node', - 'read' + 'graph_review_node' ]) }) @@ -439,7 +437,8 @@ describe('Cursor SDK runtime factory', () => { expect(String(sentMessages[0])).toContain('Kun canonical system prompt') expect(String(sentMessages[0])).toContain('Thread persona') expect(String(sentMessages[0])).toContain('Workspace AGENTS.md instruction') - expect(String(sentMessages[0])).toContain('Kun-managed tools are available') + expect(String(sentMessages[0])).toContain('Prefer Cursor built-in tools') + expect(String(sentMessages[0])).toContain('Kun-managed capabilities are available') expect(updatedMetadata).toContainEqual(expect.objectContaining({ instructionInjectionBytes: 31 })) diff --git a/kun/src/runtime/cursor/cursor-sdk-runtime-factory.ts b/kun/src/runtime/cursor/cursor-sdk-runtime-factory.ts index cecdc6294..bbf89498d 100644 --- a/kun/src/runtime/cursor/cursor-sdk-runtime-factory.ts +++ b/kun/src/runtime/cursor/cursor-sdk-runtime-factory.ts @@ -58,9 +58,9 @@ import { } from '../delegated-graph-turn-policy.js' const CURSOR_KUN_TOOL_INSTRUCTION = [ - 'Kun-managed tools are available through Cursor custom tools.', - 'Use these tools for Kun capabilities such as MCP, extensions, skills, memory, media, GUI input, and delegation.', - 'Tool execution remains governed by Kun approval and sandbox policy.' + 'Prefer Cursor built-in tools for reading, editing, searching, and running shell commands.', + 'Kun-managed capabilities are available through Cursor custom tools (MCP, extensions, skills, memory, media, GUI input, and delegation).', + 'Use those custom tools only for Kun-exclusive work; their execution remains governed by Kun approval and sandbox policy.' ].join(' ') export interface CursorSdkRuntimeFactoryDeps extends Omit< diff --git a/kun/src/runtime/cursor/cursor-sdk-tool-bridge.test.ts b/kun/src/runtime/cursor/cursor-sdk-tool-bridge.test.ts index dd4317a16..c6df8de8b 100644 --- a/kun/src/runtime/cursor/cursor-sdk-tool-bridge.test.ts +++ b/kun/src/runtime/cursor/cursor-sdk-tool-bridge.test.ts @@ -23,6 +23,62 @@ const tools: CursorBridgeTool[] = [{ inputSchema: { type: 'object' }, providerId: 'extension:render', providerKind: 'extension' +}, { + name: 'read', + description: 'Overlaps Cursor built-in read', + toolKind: 'tool_call', + inputSchema: { type: 'object' }, + providerId: 'builtin', + providerKind: 'built-in' +}, { + name: 'bash', + description: 'Overlaps Cursor built-in shell', + toolKind: 'command_execution', + inputSchema: { type: 'object' }, + providerId: 'builtin', + providerKind: 'built-in' +}, { + name: 'edit', + description: 'Overlaps Cursor built-in edit', + toolKind: 'file_change', + inputSchema: { type: 'object' }, + providerId: 'builtin', + providerKind: 'built-in' +}, { + name: 'write', + description: 'Overlaps Cursor built-in write', + toolKind: 'file_change', + inputSchema: { type: 'object' }, + providerId: 'builtin', + providerKind: 'built-in' +}, { + name: 'grep', + description: 'Overlaps Cursor built-in grep', + toolKind: 'tool_call', + inputSchema: { type: 'object' }, + providerId: 'builtin', + providerKind: 'built-in' +}, { + name: 'glob', + description: 'Overlaps Cursor built-in glob', + toolKind: 'tool_call', + inputSchema: { type: 'object' }, + providerId: 'builtin', + providerKind: 'built-in' +}, { + name: 'find', + description: 'Overlaps Cursor built-in find', + toolKind: 'tool_call', + inputSchema: { type: 'object' }, + providerId: 'builtin', + providerKind: 'built-in' +}, { + name: 'ls', + description: 'Overlaps Cursor built-in ls', + toolKind: 'tool_call', + inputSchema: { type: 'object' }, + providerId: 'builtin', + providerKind: 'built-in' }, { name: ' padded_tool ', description: 'Padded name', @@ -39,7 +95,7 @@ const tools: CursorBridgeTool[] = [{ }] describe('Cursor SDK Kun custom-tool bridge', () => { - test('keeps Kun and provider provenance (including toolKind) while excluding internal-only tools', () => { + test('bridges Kun-exclusive tools while excluding overlap and internal-only tools', () => { expect(selectCursorBridgeTools(tools).map((tool) => [ tool.name, tool.toolKind, @@ -52,6 +108,15 @@ describe('Cursor SDK Kun custom-tool bridge', () => { ]) }) + test('does not advertise overlapping Cursor built-ins as custom tools', () => { + const customTools = buildCursorCustomTools(tools, async () => ({ output: 'ok' })) + for (const name of ['read', 'bash', 'edit', 'write', 'grep', 'glob', 'find', 'ls', 'echo']) { + expect(customTools[name]).toBeUndefined() + } + expect(customTools.mcp_call_tool).toBeDefined() + expect(customTools.extension_render).toBeDefined() + }) + test('maps Cursor callbacks to Kun execution and preserves call identity and provenance', async () => { const execute = vi.fn(async () => ({ output: { ok: true, value: 42 } diff --git a/kun/src/runtime/cursor/cursor-sdk-tool-bridge.ts b/kun/src/runtime/cursor/cursor-sdk-tool-bridge.ts index 1ae9719a6..f10bc7cb3 100644 --- a/kun/src/runtime/cursor/cursor-sdk-tool-bridge.ts +++ b/kun/src/runtime/cursor/cursor-sdk-tool-bridge.ts @@ -1,3 +1,12 @@ +/** + * Re-exposes Kun-exclusive tools to the Cursor SDK as `local.customTools`. + * Cursor registers those callbacks as the `custom-user-tools` MCP server. + * + * Decision (aligned with Claude Agent SDK): tools that OVERLAP Cursor's + * built-ins (read/bash/edit/write/grep/glob/find/ls) are NOT bridged — the + * model uses Cursor's native tools. We only bridge Kun-exclusive tools such + * as MCP facades, extensions, memory, media, GUI input, and delegation. + */ import type { SDKCustomTool, SDKCustomToolContext, @@ -5,6 +14,8 @@ import type { } from '@cursor/sdk' import type { CapabilityToolSpec } from '../../adapters/tool/capability-registry.js' import { + DEFAULT_EXCLUDED_TOOL_NAMES, + DEFAULT_OVERLAP_TOOL_NAMES, mapKunResultToSdkContent, type KunToolResult } from '../agent-sdk/sdk-tool-bridge.js' @@ -26,15 +37,31 @@ export type CursorKunToolCall = { export type CursorKunToolExecutor = (call: CursorKunToolCall) => Promise -const CURSOR_BRIDGE_EXCLUDED_TOOL_NAMES = new Set(['echo']) +export interface SelectCursorBridgeOptions { + overlap?: ReadonlySet + excluded?: ReadonlySet +} + +/** + * Kun built-ins that overlap Cursor SDK built-ins — use Cursor's instead. + * Kept as an alias of the shared Claude overlap set so both delegated runtimes + * drop the same catalog names. + */ +export const CURSOR_OVERLAP_TOOL_NAMES: ReadonlySet = DEFAULT_OVERLAP_TOOL_NAMES + +/** Kun tools that are meaningless or internal-only on a Cursor turn. */ +export const CURSOR_EXCLUDED_TOOL_NAMES: ReadonlySet = DEFAULT_EXCLUDED_TOOL_NAMES export function selectCursorBridgeTools( - tools: readonly CursorBridgeTool[] + tools: readonly CursorBridgeTool[], + opts: SelectCursorBridgeOptions = {} ): CursorBridgeTool[] { + const overlap = opts.overlap ?? CURSOR_OVERLAP_TOOL_NAMES + const excluded = opts.excluded ?? CURSOR_EXCLUDED_TOOL_NAMES const seen = new Set() return tools.filter((tool) => { const name = tool.name.trim() - if (!name || seen.has(name) || CURSOR_BRIDGE_EXCLUDED_TOOL_NAMES.has(name)) return false + if (!name || seen.has(name) || overlap.has(name) || excluded.has(name)) return false seen.add(name) return true }) diff --git a/kun/src/server/graph-runtime-factory.test.ts b/kun/src/server/graph-runtime-factory.test.ts index ff4b31144..a5085671c 100644 --- a/kun/src/server/graph-runtime-factory.test.ts +++ b/kun/src/server/graph-runtime-factory.test.ts @@ -5,11 +5,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { InMemoryThreadStore } from '../adapters/in-memory-thread-store.js' import { InMemoryArtifactStore } from '../artifacts/artifact-store.js' import type { GraphRuntimeConfig } from '../config/kun-config.js' -import { GRAPH_CONTRACT_VERSION, type GraphRunV1 } from '../contracts/graph.js' +import { + GRAPH_CONTRACT_VERSION, + GraphNodeAttemptV1Schema, + type GraphRunV1 +} from '../contracts/graph.js' import { createThreadRecord } from '../domain/thread.js' import { createTurnRecord } from '../domain/turn.js' import { GraphRunConflictError } from '../graph/graph-run-store.js' import { + testAssignmentSnapshot, testGraphConfig, testGraphPlan } from '../graph/graph-test-fixtures.test-support.js' @@ -43,7 +48,8 @@ async function transitionRun( async function recordFinalSummary( runtime: GraphRuntimeComposition, run: GraphRunV1, - commandId: string + commandId: string, + finalAnswer = 'A stale Graph report was persisted before later work.' ): Promise { return (await runtime.store.append(run.id, { expectedSeq: run.lastEventSeq, @@ -55,7 +61,7 @@ async function recordFinalSummary( payload: { summary: { version: GRAPH_CONTRACT_VERSION, - finalAnswer: 'A stale Graph report was persisted before later work.', + finalAnswer, evidenceRefs: [], unresolvedRisks: [], changedFiles: [], @@ -69,6 +75,167 @@ async function recordFinalSummary( })).state } +/** Force every plan node into accepted so completion gates pass. */ +async function acceptAllNodes( + runtime: GraphRuntimeComposition, + run: GraphRunV1, + label: string +): Promise { + let current = run + for (const node of Object.values(current.nodes)) { + if (node.status === 'accepted' || node.status === 'superseded') continue + const nodeId = node.node.id + if (current.nodes[nodeId]!.status === 'pending') { + current = (await runtime.store.append(current.id, { + expectedSeq: current.lastEventSeq, + graphRevision: current.currentRevision, + commandId: `${label}_${nodeId}_ready`, + idempotencyKey: `${label}_${nodeId}_ready`, + event: { + type: 'node_status_changed', + payload: { + nodeId, + from: 'pending', + to: 'ready', + reason: 'test fixture: semantic work complete' + } + } + })).state + } + const attemptId = `attempt_${label}_${nodeId}` + const attempt = GraphNodeAttemptV1Schema.parse({ + version: GRAPH_CONTRACT_VERSION, + id: attemptId, + runId: current.id, + nodeId, + revision: current.currentRevision, + attemptNumber: 1, + iteration: 0, + commandId: `${label}_${nodeId}_attempt`, + idempotencyKey: `${label}_${nodeId}_attempt`, + status: 'queued', + assignment: testAssignmentSnapshot(), + queuedAt: '2026-07-26T00:00:00.000Z', + tokenUsage: 0, + elapsedMs: 0 + }) + // attempt_created admits on ready and moves the node to queued. + const events = [ + { type: 'attempt_created' as const, payload: { attempt } }, + { + type: 'attempt_status_changed' as const, + payload: { + nodeId, + attemptId, + from: 'queued' as const, + to: 'running' as const + } + }, + { + type: 'node_status_changed' as const, + payload: { + nodeId, + from: 'queued' as const, + to: 'running' as const, + reason: 'test fixture: semantic work complete' + } + }, + { + type: 'attempt_status_changed' as const, + payload: { + nodeId, + attemptId, + from: 'running' as const, + to: 'submitted' as const + } + }, + { + type: 'node_status_changed' as const, + payload: { + nodeId, + from: 'running' as const, + to: 'submitted' as const, + reason: 'test fixture: semantic work complete' + } + }, + { + type: 'attempt_status_changed' as const, + payload: { + nodeId, + attemptId, + from: 'submitted' as const, + to: 'accepted' as const + } + }, + { + type: 'node_status_changed' as const, + payload: { + nodeId, + from: 'submitted' as const, + to: 'accepted' as const, + reason: 'test fixture: semantic work complete' + } + } + ] + for (const [index, event] of events.entries()) { + current = (await runtime.store.append(current.id, { + expectedSeq: current.lastEventSeq, + graphRevision: current.currentRevision, + commandId: `${label}_${nodeId}_accept_${index}`, + idempotencyKey: `${label}_${nodeId}_accept_${index}`, + event + })).state + } + } + return current +} + +async function createOwnedGraphRuntime(label: string): Promise<{ + runtime: GraphRuntimeComposition + threadId: string + sourceTurnId: string + workspace: string + root: string + threadStore: InMemoryThreadStore +}> { + const root = await mkdtemp(join(tmpdir(), `kun-graph-runtime-${label}-`)) + const workspace = join(root, 'workspace') + await mkdir(workspace) + roots.push(root) + let id = 0 + const threadStore = new InMemoryThreadStore() + const threadId = `thread_${label}` + const sourceTurnId = `turn_${label}` + const thread = createThreadRecord({ + id: threadId, + title: `Graph ${label}`, + workspace, + model: 'test-model' + }) + await threadStore.upsert({ + ...thread, + turns: [ + createTurnRecord({ + id: sourceTurnId, + threadId, + prompt: 'Build a graph.', + orchestration: 'graph', + status: 'running' + }) + ] + }) + const runtime = new GraphRuntimeComposition({ + dataDir: root, + config: () => testGraphConfig(), + artifactStore: new InMemoryArtifactStore(), + runtimeEvents: { record: vi.fn(async (event) => event as never) }, + threadStore, + ids: { next: (prefix) => `${prefix}_${++id}` }, + nowIso: () => '2026-07-26T00:00:00.000Z' + }) + return { runtime, threadId, sourceTurnId, workspace, root, threadStore } +} + describe('GraphRuntimeComposition creation authority', () => { it('binds HTTP/tool creation inputs to the canonical parent thread and source turn', async () => { const root = await mkdtemp(join(tmpdir(), 'kun-graph-runtime-authority-')) @@ -720,3 +887,484 @@ describe('GraphRuntimeComposition creation authority', () => { await runtime.stop() }) }) + +function testAuthority(workspace: string) { + return { + workspaceRoot: workspace, + model: 'test-model', + providerId: 'default', + allowedModelProviderIds: ['default'], + allowedModels: ['test-model'], + allowedProviderIds: [], + reasoningEffort: 'off' as const, + approvalPolicy: 'never' as const, + sandboxMode: 'read-only' as const, + allowedTools: [] as string[], + blockedTools: [] as string[], + allowedSkills: [] as string[], + blockedSkills: [] as string[], + allowedMcpServers: [] as string[], + blockedMcpServers: [] as string[], + readScopes: ['.'], + writeScopes: [] as string[], + networkAllowed: false + } +} + +async function startOwnedRuntime( + runtime: GraphRuntimeComposition, + workspace: string +): Promise { + await runtime.start({ + delegation: () => undefined, + leadTurn: async () => undefined, + authorityForRun: () => testAuthority(workspace) + }) +} + +async function settleSourceTurn( + threadStore: InMemoryThreadStore, + threadId: string, + sourceTurnId: string, + status: 'failed' | 'aborted' +): Promise { + const thread = await threadStore.get(threadId) + if (!thread) throw new Error(`missing thread ${threadId}`) + await threadStore.upsert({ + ...thread, + turns: thread.turns.map((turn) => + turn.id === sourceTurnId ? { ...turn, status } : turn) + }) +} + +function spyResumeRun(runtime: GraphRuntimeComposition) { + const original = runtime.scheduler.resumeRun.bind(runtime.scheduler) + return vi.spyOn(runtime.scheduler, 'resumeRun').mockImplementation(async (runId) => + original(runId)) +} + +async function expectNoCancelledTransition( + runtime: GraphRuntimeComposition, + runId: string +): Promise { + expect( + (await runtime.store.events(runId, 0)).some((envelope) => + envelope.event.type === 'run_status_changed' && + envelope.event.payload.to === 'cancelled') + ).toBe(false) +} + +describe('GraphRuntimeComposition source-turn terminal semantics (#1071)', () => { + it('converges a completing run to completed after incidental aborted when scheduler is started', async () => { + // start() first so the initial scheduler tick is empty; then construct the + // completing run. That way completed can only come from preserve→resumeRun. + const { runtime, threadId, sourceTurnId, workspace, threadStore } = + await createOwnedGraphRuntime('completing_live') + await startOwnedRuntime(runtime, workspace) + const identity = await runtime.registry.identify(workspace) + await runtime.control.create({ + runId: 'run_completing_live', + threadId, + projectId: identity.projectId, + sourceTurnId, + plan: testGraphPlan({ workspaceRoot: workspace }), + commandId: 'create_completing_live', + idempotencyKey: 'create_completing_live' + }) + let run = await runtime.control.get('run_completing_live') + run = await transitionRun(runtime, run, 'running', 'to_running_completing_live') + run = await acceptAllNodes(runtime, run, 'completing_live') + run = await transitionRun(runtime, run, 'completing', 'to_completing_live') + expect(run.status).toBe('completing') + expect(run.summary).toBeUndefined() + + await settleSourceTurn(threadStore, threadId, sourceTurnId, 'aborted') + const resume = spyResumeRun(runtime) + await runtime.handleSourceTurnTerminal(threadId, sourceTurnId, 'aborted') + + expect(resume).toHaveBeenCalledTimes(1) + expect(resume).toHaveBeenCalledWith(run.id) + const after = await runtime.control.get(run.id) + expect(after.status).toBe('completed') + expect(after.summary).toBeDefined() + expect(after.summary!.finalAnswer.length).toBeGreaterThan(0) + await expectNoCancelledTransition(runtime, run.id) + await runtime.stop() + }) + + it('converges gates-passed running work to completed after incidental failure when scheduler is started', async () => { + // Remaining race beyond v0.2.35: gates passed, no summary/completing yet. + // start() first → empty tick; then install the running gates-passed snapshot. + const { runtime, threadId, sourceTurnId, workspace, threadStore } = + await createOwnedGraphRuntime('gates_live') + await startOwnedRuntime(runtime, workspace) + const identity = await runtime.registry.identify(workspace) + await runtime.control.create({ + runId: 'run_gates_live', + threadId, + projectId: identity.projectId, + sourceTurnId, + plan: testGraphPlan({ workspaceRoot: workspace }), + commandId: 'create_gates_live', + idempotencyKey: 'create_gates_live' + }) + let run = await runtime.control.get('run_gates_live') + run = await transitionRun(runtime, run, 'running', 'to_running_gates_live') + run = await acceptAllNodes(runtime, run, 'gates_live') + expect(run.status).toBe('running') + expect(run.summary).toBeUndefined() + expect(Object.values(run.nodes).every((node) => node.status === 'accepted')).toBe(true) + + await settleSourceTurn(threadStore, threadId, sourceTurnId, 'failed') + const resume = spyResumeRun(runtime) + await runtime.handleSourceTurnTerminal(threadId, sourceTurnId, 'failed') + + expect(resume).toHaveBeenCalledTimes(1) + expect(resume).toHaveBeenCalledWith(run.id) + const after = await runtime.control.get(run.id) + expect(after.status).toBe('completed') + expect(after.summary).toBeDefined() + expect(after.summary!.finalAnswer.length).toBeGreaterThan(0) + expect(after.finishedAt).toBeTruthy() + await expectNoCancelledTransition(runtime, run.id) + await runtime.stop() + }) + + it('preserves accepted+summary awaiting_supervision and finishes when finalization is safe', async () => { + const { runtime, threadId, sourceTurnId, workspace, threadStore } = + await createOwnedGraphRuntime('accepted_summary_live') + await startOwnedRuntime(runtime, workspace) + const identity = await runtime.registry.identify(workspace) + await runtime.control.create({ + runId: 'run_accepted_summary_live', + threadId, + projectId: identity.projectId, + sourceTurnId, + plan: testGraphPlan({ workspaceRoot: workspace }), + commandId: 'create_accepted_summary_live', + idempotencyKey: 'create_accepted_summary_live' + }) + let run = await runtime.control.get('run_accepted_summary_live') + run = await transitionRun(runtime, run, 'running', 'to_running_accepted_live') + run = await acceptAllNodes(runtime, run, 'accepted_summary_live') + run = await recordFinalSummary( + runtime, + run, + 'summary_accepted_live', + 'All nodes accepted and the final report is complete.' + ) + run = await transitionRun(runtime, run, 'awaiting_supervision', 'hold_after_summary_live') + expect(run.status).toBe('awaiting_supervision') + expect(run.summary?.finalAnswer).toContain('final report is complete') + + await settleSourceTurn(threadStore, threadId, sourceTurnId, 'failed') + const resume = spyResumeRun(runtime) + await runtime.handleSourceTurnTerminal(threadId, sourceTurnId, 'failed') + + expect(resume).toHaveBeenCalledTimes(1) + expect(resume).toHaveBeenCalledWith(run.id) + const after = await runtime.control.get(run.id) + expect(after.status).toBe('completed') + expect(after.summary?.finalAnswer).toContain('final report is complete') + await expectNoCancelledTransition(runtime, run.id) + await runtime.stop() + }) + + it('does not auto-finish gates-passed work with an unresolved blocking mailbox message', async () => { + const { runtime, threadId, sourceTurnId, workspace, threadStore } = + await createOwnedGraphRuntime('mailbox_block') + await startOwnedRuntime(runtime, workspace) + const identity = await runtime.registry.identify(workspace) + await runtime.control.create({ + runId: 'run_mailbox_block', + threadId, + projectId: identity.projectId, + sourceTurnId, + plan: testGraphPlan({ workspaceRoot: workspace }), + commandId: 'create_mailbox_block', + idempotencyKey: 'create_mailbox_block' + }) + let run = await runtime.control.get('run_mailbox_block') + run = await transitionRun(runtime, run, 'running', 'to_running_mailbox') + run = await acceptAllNodes(runtime, run, 'mailbox_block') + await runtime.mailbox.send({ + id: 'message_blocking_1', + runId: run.id, + sender: { kind: 'system' }, + recipients: [{ kind: 'worker', nodeId: 'finish' }], + type: 'system', + priority: 'blocking', + summary: 'Confirm the handoff before finalization.', + artifactRefs: [], + replyRequired: true + }, { commandId: 'send_block_1', idempotencyKey: 'send_block_1' }) + run = (await runtime.store.get(run.id))! + expect(runtime.mailbox.unresolvedBlockers(run).length).toBeGreaterThan(0) + expect(run.status).toBe('running') + + await settleSourceTurn(threadStore, threadId, sourceTurnId, 'failed') + const resume = spyResumeRun(runtime) + await runtime.handleSourceTurnTerminal(threadId, sourceTurnId, 'failed') + + // Semantic complete forbids cancel; finalization unsafe forbids resumeRun. + expect(resume).not.toHaveBeenCalled() + const after = await runtime.control.get(run.id) + expect(after.status).not.toBe('cancelled') + expect(after.status).not.toBe('completed') + expect(runtime.mailbox.unresolvedBlockers(after).length).toBeGreaterThan(0) + await runtime.stop() + }) + + it('keeps awaiting_human with needs_attention after incidental settlement', async () => { + const { runtime, threadId, sourceTurnId, workspace, threadStore } = + await createOwnedGraphRuntime('human_hold') + await startOwnedRuntime(runtime, workspace) + const identity = await runtime.registry.identify(workspace) + await runtime.control.create({ + runId: 'run_human_hold', + threadId, + projectId: identity.projectId, + sourceTurnId, + plan: testGraphPlan({ workspaceRoot: workspace }), + commandId: 'create_human_hold', + idempotencyKey: 'create_human_hold' + }) + let run = await runtime.control.get('run_human_hold') + run = await transitionRun(runtime, run, 'running', 'to_running_human') + run = await acceptAllNodes(runtime, run, 'human_hold') + run = await recordFinalSummary(runtime, run, 'summary_human', 'Semantic work finished.') + const obligation = { + version: GRAPH_CONTRACT_VERSION, + id: 'graph_obligation_human_hold', + kind: 'help' as const, + reason: 'help' as const, + graphRevision: run.currentRevision, + nodeIds: [] as string[], + attemptIds: [] as string[], + digest: 'Human attention required before finalization.', + state: 'needs_attention' as const, + deliveryAttempts: 1, + noProgressCount: 3, + lastProgressSeq: run.lastEventSeq, + attentionReason: 'Source Lead made no progress; human review required.', + createdAt: '2026-07-26T00:00:00.000Z', + updatedAt: '2026-07-26T00:00:00.000Z' + } + run = (await runtime.store.append(run.id, { + expectedSeq: run.lastEventSeq, + graphRevision: run.currentRevision, + commandId: 'open_human_hold', + idempotencyKey: 'open_human_hold', + event: { + type: 'supervision_obligation_updated', + payload: { obligation } + } + })).state + run = await transitionRun(runtime, run, 'awaiting_human', 'to_awaiting_human') + expect(run.status).toBe('awaiting_human') + + await settleSourceTurn(threadStore, threadId, sourceTurnId, 'failed') + const resume = spyResumeRun(runtime) + await runtime.handleSourceTurnTerminal(threadId, sourceTurnId, 'failed') + + expect(resume).not.toHaveBeenCalled() + const after = await runtime.control.get(run.id) + expect(after.status).toBe('awaiting_human') + expect(after.status).not.toBe('completed') + expect(after.status).not.toBe('cancelled') + expect(after.supervisionObligations.some((entry) => + entry.id === obligation.id && entry.state === 'needs_attention')).toBe(true) + expect(after.summary?.finalAnswer).toContain('Semantic work finished') + await runtime.stop() + }) + + it('does not auto-complete awaiting_supervision with an unresolved scheduler_error obligation', async () => { + const { runtime, threadId, sourceTurnId, workspace, threadStore } = + await createOwnedGraphRuntime('sched_err') + await startOwnedRuntime(runtime, workspace) + const identity = await runtime.registry.identify(workspace) + await runtime.control.create({ + runId: 'run_sched_err', + threadId, + projectId: identity.projectId, + sourceTurnId, + plan: testGraphPlan({ workspaceRoot: workspace }), + commandId: 'create_sched_err', + idempotencyKey: 'create_sched_err' + }) + let run = await runtime.control.get('run_sched_err') + run = await transitionRun(runtime, run, 'running', 'to_running_sched_err') + run = await acceptAllNodes(runtime, run, 'sched_err') + run = await recordFinalSummary(runtime, run, 'summary_sched_err', 'Gates passed.') + const obligation = { + version: GRAPH_CONTRACT_VERSION, + id: 'graph_obligation_sched_err', + kind: 'scheduler_error' as const, + reason: 'scheduler_error' as const, + graphRevision: run.currentRevision, + nodeIds: [] as string[], + attemptIds: [] as string[], + digest: 'Scheduler failed while finalizing.', + state: 'pending' as const, + deliveryAttempts: 0, + noProgressCount: 0, + lastProgressSeq: run.lastEventSeq, + createdAt: '2026-07-26T00:00:00.000Z', + updatedAt: '2026-07-26T00:00:00.000Z' + } + run = (await runtime.store.append(run.id, { + expectedSeq: run.lastEventSeq, + graphRevision: run.currentRevision, + commandId: 'open_sched_err', + idempotencyKey: 'open_sched_err', + event: { + type: 'supervision_obligation_opened', + payload: { obligation } + } + })).state + run = await transitionRun(runtime, run, 'awaiting_supervision', 'to_awaiting_sched_err') + expect(run.status).toBe('awaiting_supervision') + + await settleSourceTurn(threadStore, threadId, sourceTurnId, 'failed') + const resume = spyResumeRun(runtime) + await runtime.handleSourceTurnTerminal(threadId, sourceTurnId, 'failed') + + expect(resume).not.toHaveBeenCalled() + const after = await runtime.control.get(run.id) + expect(after.status).not.toBe('completed') + expect(after.status).not.toBe('cancelled') + expect(after.status).toBe('awaiting_supervision') + expect(after.supervisionObligations.some((entry) => + entry.id === obligation.id && entry.state !== 'resolved')).toBe(true) + await runtime.stop() + }) + + it('leaves gates-passed work uncancelled without finishing when scheduler is not started (cold-start)', async () => { + // Cold composition before runtime.start: incidental settlement must not + // cancel, but cannot finish without a scheduler. Explicit cold-start semantics. + const { runtime, threadId, sourceTurnId, workspace } = await createOwnedGraphRuntime('cold_start') + const identity = await runtime.registry.identify(workspace) + await runtime.control.create({ + runId: 'run_cold_start', + threadId, + projectId: identity.projectId, + sourceTurnId, + plan: testGraphPlan({ workspaceRoot: workspace }), + commandId: 'create_cold_start', + idempotencyKey: 'create_cold_start' + }) + let run = await runtime.control.get('run_cold_start') + run = await transitionRun(runtime, run, 'running', 'to_running_cold') + run = await acceptAllNodes(runtime, run, 'cold_start') + + await runtime.handleSourceTurnTerminal(threadId, sourceTurnId, 'failed') + const after = await runtime.control.get(run.id) + expect(after.status).not.toBe('cancelled') + expect(['running', 'completing']).toContain(after.status) + expect(after.summary).toBeUndefined() + await runtime.stop() + }) + + it('force-cancels even a completing run for explicit user Stop', async () => { + const { runtime, threadId, sourceTurnId, workspace } = await createOwnedGraphRuntime('force_stop') + const identity = await runtime.registry.identify(workspace) + await runtime.control.create({ + runId: 'run_force_stop', + threadId, + projectId: identity.projectId, + sourceTurnId, + plan: testGraphPlan({ workspaceRoot: workspace }), + commandId: 'create_force_stop', + idempotencyKey: 'create_force_stop' + }) + let run = await runtime.control.get('run_force_stop') + run = await transitionRun(runtime, run, 'running', 'to_running_force') + run = await acceptAllNodes(runtime, run, 'force_stop') + run = await recordFinalSummary(runtime, run, 'summary_force_stop', 'Semantic work finished.') + run = await transitionRun(runtime, run, 'completing', 'to_completing_force') + // Do not start the scheduler first: a tick would finish completing before + // Stop. Explicit cancel must work against a durable completing snapshot. + + await runtime.cancelSourceTurnRunsExplicitly(threadId, sourceTurnId) + await expect(runtime.control.get(run.id)).resolves.toMatchObject({ + status: 'cancelled' + }) + expect( + (await runtime.store.events(run.id, 0)).some((envelope) => + envelope.event.type === 'run_status_changed' && + envelope.event.payload.to === 'cancelled' && + envelope.event.payload.reason === 'user interrupted the owning source turn') + ).toBe(true) + await runtime.stop() + }) + + it('still cancels unfinished owned runs on incidental settlement', async () => { + const { runtime, threadId, sourceTurnId, workspace } = await createOwnedGraphRuntime('unfinished') + const identity = await runtime.registry.identify(workspace) + await runtime.control.create({ + runId: 'run_unfinished', + threadId, + projectId: identity.projectId, + sourceTurnId, + plan: testGraphPlan({ workspaceRoot: workspace }), + commandId: 'create_unfinished', + idempotencyKey: 'create_unfinished' + }) + let run = await runtime.control.get('run_unfinished') + run = await transitionRun(runtime, run, 'running', 'to_running_unfinished') + expect(run.nodes.research?.status).not.toBe('accepted') + await startOwnedRuntime(runtime, workspace) + + await runtime.handleSourceTurnTerminal(threadId, sourceTurnId, 'aborted') + await expect(runtime.control.get(run.id)).resolves.toMatchObject({ + status: 'cancelled' + }) + await runtime.stop() + }) + + it('treats concurrent completion as a successful terminal fence for cancel races', async () => { + const { runtime, threadId, sourceTurnId, workspace } = await createOwnedGraphRuntime('race') + const identity = await runtime.registry.identify(workspace) + await runtime.control.create({ + runId: 'run_race', + threadId, + projectId: identity.projectId, + sourceTurnId, + plan: testGraphPlan({ workspaceRoot: workspace }), + commandId: 'create_race', + idempotencyKey: 'create_race' + }) + let run = await runtime.control.get('run_race') + run = await transitionRun(runtime, run, 'running', 'to_running_race') + run = await transitionRun(runtime, run, 'completing', 'to_completing_race') + + const originalList = runtime.store.list.bind(runtime.store) + vi.spyOn(runtime.store, 'list').mockImplementation(async (query) => { + const listed = await originalList(query) + for (const item of listed) { + if (item.id !== run.id || item.status === 'completed') continue + const latest = (await runtime.store.get(item.id))! + if (latest.status === 'completed') continue + await runtime.store.append(latest.id, { + expectedSeq: latest.lastEventSeq, + graphRevision: latest.currentRevision, + commandId: 'complete_race_win', + idempotencyKey: 'complete_race_win', + event: { + type: 'run_status_changed', + payload: { from: latest.status, to: 'completed' } + } + }) + } + return listed + }) + + await expect( + runtime.cancelSourceTurnRunsExplicitly(threadId, sourceTurnId) + ).resolves.toBeUndefined() + await expect(runtime.control.get(run.id)).resolves.toMatchObject({ + status: 'completed' + }) + await runtime.stop() + }) +}) diff --git a/kun/src/server/graph-runtime-factory.ts b/kun/src/server/graph-runtime-factory.ts index 387295cb7..a38bad3bd 100644 --- a/kun/src/server/graph-runtime-factory.ts +++ b/kun/src/server/graph-runtime-factory.ts @@ -35,7 +35,10 @@ import type { SessionStore } from '../ports/session-store.js' import type { ThreadStore } from '../ports/thread-store.js' import type { RuntimeEventRecorder } from '../services/runtime-event-recorder.js' import { createGraphCheckVerifier } from '../graph/graph-check-verifier.js' -import { isGraphRunCompletionFinalizing } from '../graph/graph-run-completion.js' +import { + isGraphRunCompletionFinalizing, + isGraphRunSemanticComplete +} from '../graph/graph-run-completion.js' import { recoverGraphLeadOwnership, recoverGraphPlanningCommits @@ -419,6 +422,21 @@ export class GraphRuntimeComposition { } } + /** + * Explicit user Stop / interruptTurn fence. Always cancels owned nonterminal + * GraphRuns before the source turn is persisted as aborted. Do not use for + * incidental Lead settlement (model failure, approval expiry, normal + * completed turn) — call {@link handleSourceTurnTerminal} without force. + */ + async cancelSourceTurnRunsExplicitly( + threadId: string, + sourceTurnId: string + ): Promise { + await this.handleSourceTurnTerminal(threadId, sourceTurnId, 'aborted', { + forceCancel: true + }) + } + async handleSourceTurnTerminal( threadId: string, sourceTurnId: string, @@ -437,19 +455,30 @@ export class GraphRuntimeComposition { } if ( options.forceCancel !== true && - isGraphRunCompletionFinalizing(run, this.mailbox) + isGraphRunSemanticComplete(run) ) { - // Scheduler owns idempotent cleanup and completion. Wake it when the - // source Lead has just failed so a persisted final summary can finish - // without needing another Lead episode. - await this.scheduler?.resumeRun(run.id) + // Incidental settlement: never cancel semantically finished work. + // resumeRun only auto-finishes when finalization is also safe + // (no mailbox / human / scheduler holds). Explicit Stop uses + // forceCancel and must not take this branch (#1071). + if (isGraphRunCompletionFinalizing(run, this.mailbox)) { + await this.scheduler?.resumeRun(run.id) + } continue } try { await this.control.cancel(run.id, { - commandId: this.options.ids.next('graph_source_turn_terminal'), - idempotencyKey: `source-turn-terminal:${threadId}:${sourceTurnId}:${run.id}:${status}`, - reason: `owning source turn ended with status ${status}` + commandId: this.options.ids.next( + options.forceCancel === true + ? 'graph_source_turn_stop' + : 'graph_source_turn_terminal' + ), + idempotencyKey: options.forceCancel === true + ? `source-turn-stop:${threadId}:${sourceTurnId}:${run.id}` + : `source-turn-terminal:${threadId}:${sourceTurnId}:${run.id}:${status}`, + reason: options.forceCancel === true + ? 'user interrupted the owning source turn' + : `owning source turn ended with status ${status}` }) } catch (error) { // Completion may win after list() but before cancel(). That is already diff --git a/kun/src/server/routes/index.ts b/kun/src/server/routes/index.ts index f63353d80..9a58d3190 100644 --- a/kun/src/server/routes/index.ts +++ b/kun/src/server/routes/index.ts @@ -10,6 +10,7 @@ import { getThreadGoal, getThreadTodos, getThread, + getThreadState, listThreads, setThreadGoal, setThreadTodos, @@ -18,6 +19,7 @@ import { import { summarizeThread } from './threads-summarize.js' import { compactTurn, + cancelToolCall, getSteeringQueue, getTurn, interruptTurn, @@ -170,7 +172,7 @@ import { * - `POST /v1/delegation/abort/{childId}` (auth) * - `GET /v1/workspace/status` (auth) * - `GET/POST /v1/threads` (auth) - * - `GET/PATCH/DELETE /v1/threads/{id}` (auth) + * - `GET/PATCH/DELETE /v1/threads/{id}` and `GET /v1/threads/{id}/state` (auth) * - `GET /v1/threads/{id}/model-requests` (auth) * - `POST /v1/threads/{id}/fork` (auth) * - `POST /v1/threads/{id}/summarize` (auth) @@ -675,6 +677,14 @@ export function buildRouter(runtime: ServerRuntime): Router { if (!authorize(request, runtime)) return ERRORS.unauthorized() return createThread(runtime.threadService, request) }) + // This static suffix must be registered before `/:id`, because Router uses + // first-match ordering for parameterized paths. + router.add('GET', '/v1/threads/:id/state', async (request, ctx) => { + if (!authorize(request, runtime)) return ERRORS.unauthorized() + const forwarded = await runtime.forwardThreadControl?.(request, ctx.params.id) + if (forwarded) return forwarded + return getThreadState(runtime.threadService, ctx.params.id, runtime.sessionStore) + }) router.add('GET', '/v1/threads/:id', async (request, ctx) => { if (!authorize(request, runtime)) return ERRORS.unauthorized() // The active approval gate is process-local. When a manager lease belongs @@ -800,6 +810,17 @@ export function buildRouter(runtime: ServerRuntime): Router { if (forwarded) return forwarded return interruptTurn(runtime.turnService, ctx.params.id, ctx.params.turnId, request) }) + router.add('POST', '/v1/threads/:id/turns/:turnId/tool-calls/:callId/cancel', async (request, ctx) => { + if (!authorize(request, runtime)) return ERRORS.unauthorized() + const forwarded = await runtime.forwardThreadControl?.(request, ctx.params.id) + if (forwarded) return forwarded + return cancelToolCall( + runtime.toolCancellationService, + ctx.params.id, + ctx.params.turnId, + ctx.params.callId + ) + }) router.add('POST', '/v1/threads/:id/compact', async (request, ctx) => { if (!authorize(request, runtime)) return ERRORS.unauthorized() return compactTurn(runtime.turnService, ctx.params.id, request) diff --git a/kun/src/server/routes/server-runtime.ts b/kun/src/server/routes/server-runtime.ts index a85cdd887..44db37fbb 100644 --- a/kun/src/server/routes/server-runtime.ts +++ b/kun/src/server/routes/server-runtime.ts @@ -94,6 +94,7 @@ import type { ModelConnectionRegistry } from '../../services/model-connection-re import type { ModelConnectionOAuthService } from '../../services/model-connection-oauth.js' import type { OfficialProviderAuthService } from '../../services/official-provider-cli.js' import type { ProviderQuotaService } from '../../services/provider-quota-service.js' +import type { ToolCancellationService } from '../../services/tool-cancellation-service.js' export type RuntimeToolDiagnostics = { providers: ToolProviderPolicy[] @@ -162,6 +163,7 @@ export type ExtensionPlatformRuntime = { export type ServerRuntime = { threadService: ThreadService turnService: TurnService + toolCancellationService?: ToolCancellationService usageService: UsageService reviewService?: ReviewService eventBus: EventBus diff --git a/kun/src/server/routes/threads.test.ts b/kun/src/server/routes/threads.test.ts index 198bde87a..ff3cba5bc 100644 --- a/kun/src/server/routes/threads.test.ts +++ b/kun/src/server/routes/threads.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { forkThread, getThread, updateThread } from './threads.js' +import { forkThread, getThread, getThreadState, updateThread } from './threads.js' import { buildRouter } from './index.js' import type { ServerRuntime } from './server-runtime.js' import { createThreadRecord } from '../../domain/thread.js' @@ -118,6 +118,50 @@ describe('getThread replay snapshot boundary (#1087)', () => { }) }) +describe('getThreadState', () => { + it('returns only metadata and never loads session item history', async () => { + const record = createThreadRecord({ + id: 'thr_state', title: 'State', workspace: '/tmp', model: 'deepseek-chat', status: 'running' + }) + record.turns = [createTurnRecord({ + id: 'turn_state', threadId: record.id, prompt: 'continue', status: 'running', + createdAt: '2026-08-07T00:00:00.000Z' + })] + const getMetadata = vi.fn(async () => record) + const loadItems = vi.fn(async () => { + throw new Error('state route must not load items') + }) + const response = await getThreadState({ + get: vi.fn(async () => record), + getMetadata + } as unknown as ThreadService, record.id, { + highestSeq: vi.fn(async () => 73), + loadItems + } as never) + + expect(response.status).toBe(200) + expect(JSON.parse(response.body)).toEqual({ + id: record.id, + status: 'running', + updatedAt: record.updatedAt, + latestSeq: 73, + latestTurn: { id: 'turn_state', status: 'running', orchestration: 'direct' } + }) + expect(getMetadata).toHaveBeenCalledWith(record.id) + expect(loadItems).not.toHaveBeenCalled() + }) + + it('returns the normal 404 response for a missing thread', async () => { + const response = await getThreadState({ + get: vi.fn(async () => null), + getMetadata: vi.fn(async () => null) + } as unknown as ThreadService, 'thr_missing') + + expect(response.status).toBe(404) + expect(JSON.parse(response.body)).toMatchObject({ code: 'not_found' }) + }) +}) + describe('getThread session-only goal context', () => { it('does not expose durable goal context through the renderer hydration snapshot', async () => { const record = createThreadRecord({ @@ -348,4 +392,24 @@ describe('GET /v1/threads/:id active-owner forwarding (#1053)', () => { expect(forwardThreadControl).toHaveBeenCalledWith(request, 'thr_owner') expect(result).toBe(forwarded) }) + + it('registers the authenticated lightweight state route before the generic detail route', async () => { + const forwarded = new Response(JSON.stringify({ state: true }), { status: 200 }) + const forwardThreadControl = vi.fn(async () => forwarded) + const router = buildRouter({ + runtimeToken: 'thread-route-token', insecure: false, forwardThreadControl + } as unknown as ServerRuntime) + const authorized = new Request('http://127.0.0.1/v1/threads/thr_owner/state', { + headers: { authorization: 'Bearer thread-route-token' } + }) + const match = router.match('GET', new URL(authorized.url).pathname) + if (!match) throw new Error('thread state route not found') + + expect(await match.handler(authorized, { params: match.params })).toBe(forwarded) + expect(forwardThreadControl).toHaveBeenCalledWith(authorized, 'thr_owner') + + const unauthorized = new Request('http://127.0.0.1/v1/threads/thr_owner/state') + const rejected = await match.handler(unauthorized, { params: match.params }) + expect(rejected.status).toBe(401) + }) }) diff --git a/kun/src/server/routes/threads.ts b/kun/src/server/routes/threads.ts index 2512941bc..3ccadf312 100644 --- a/kun/src/server/routes/threads.ts +++ b/kun/src/server/routes/threads.ts @@ -9,6 +9,7 @@ import { SetThreadGoalRequest, SetThreadTodosRequest, ThreadGoalResponse, + ThreadRuntimeStateSchema, ThreadSchema, ThreadTodosResponse, UpdateThreadRequest, @@ -104,7 +105,21 @@ export async function getThread( // replayable without creating either an old-state/new-cursor gap or duplicate // assistant text. const latestSeq = sessionStore ? await sessionStore.highestSeq(threadId) : 0 - const thread = await service.get(threadId) + // With a durable session store, the thread metadata and items are separate + // projections. Read only metadata here, then hydrate item history once + // below; `service.get()` would otherwise transfer the same history first. + let thread: ThreadRecord | null + let loadedSessionItems: TurnItem[] | undefined + if (sessionStore) { + const loaded = await Promise.all([ + loadThreadMetadata(service, threadId), + sessionStore.loadItems(threadId) + ]) + thread = loaded[0] + loadedSessionItems = loaded[1] + } else { + thread = await service.get(threadId) + } if (!thread) { return jsonResponse( { code: 'not_found', message: `thread not found: ${threadId}` }, @@ -114,7 +129,7 @@ export async function getThread( const pendingApprovals = approvalGate?.pending(threadId) ?? [] let sessionItems: TurnItem[] = [] if (sessionStore) { - sessionItems = await sessionStore.loadItems(threadId) + sessionItems = loadedSessionItems ?? [] sessionItems = await healSessionItemsForFinishedTurns(thread, sessionItems, sessionStore) } else if (pendingApprovals.length > 0) { // Tests and lightweight embedded callers can omit the session store. Use @@ -149,6 +164,48 @@ export async function getThread( }) } +/** + * Return just enough state to decide whether a background thread is still + * running. This route intentionally never reads session items. + */ +export async function getThreadState( + service: ThreadService, + threadId: string, + sessionStore?: SessionStore +): Promise { + const latestSeq = sessionStore ? await sessionStore.highestSeq(threadId) : 0 + const thread = await loadThreadMetadata(service, threadId) + if (!thread) { + return jsonResponse( + { code: 'not_found', message: `thread not found: ${threadId}` }, + 404 + ) + } + const latestTurn = thread.turns.at(-1) + return jsonResponse(ThreadRuntimeStateSchema.parse({ + id: thread.id, + status: thread.status, + updatedAt: thread.updatedAt, + latestSeq, + latestTurn: latestTurn + ? { + id: latestTurn.id, + status: latestTurn.status, + orchestration: latestTurn.orchestration === 'graph' ? 'graph' : 'direct' + } + : null + })) +} + +function loadThreadMetadata(service: ThreadService, threadId: string): Promise { + // Keep direct route-unit fakes and third-party ThreadService facades from + // needing a coordinated upgrade; production ThreadService always exposes + // getMetadata and takes the lightweight path. + return typeof service.getMetadata === 'function' + ? service.getMetadata(threadId) + : service.get(threadId) +} + function mergePendingApprovalItems( sessionItems: TurnItem[], pendingApprovals: readonly ApprovalRequest[] diff --git a/kun/src/server/routes/turns.test.ts b/kun/src/server/routes/turns.test.ts index 1d2d837b8..b1d30c4a8 100644 --- a/kun/src/server/routes/turns.test.ts +++ b/kun/src/server/routes/turns.test.ts @@ -12,7 +12,7 @@ import { SequentialIdGenerator } from '../../ports/id-generator.js' import { RuntimeEventRecorder } from '../../services/runtime-event-recorder.js' import { TurnService } from '../../services/turn-service.js' import type { JsonResponse } from '../response.js' -import { getTurn, rewindThread, startTurn, steerTurn } from './turns.js' +import { cancelToolCall, getTurn, rewindThread, startTurn, steerTurn } from './turns.js' describe('GET /v1/threads/:id/turns/:turnId public-item boundary', () => { it('does not expose a legacy internal goal context from the raw turn mirror', async () => { @@ -69,6 +69,48 @@ describe('POST /v1/threads/:id/turns/:turnId/steer execution', () => { }) }) +describe('POST /v1/threads/:id/turns/:turnId/tool-calls/:callId/cancel', () => { + it('returns the accepted cancellation status without requiring a request body', async () => { + const cancellation = { + cancel: vi.fn(async (input: { threadId: string; turnId: string; callId: string }) => ({ + ...input, + status: 'cancellation_requested' as const + })) + } + const response = await cancelToolCall( + cancellation as never, + 'thread_1', + 'turn_1', + 'call_1' + ) as JsonResponse + + expect(response.status).toBe(200) + expect(JSON.parse(response.body)).toEqual({ + threadId: 'thread_1', + turnId: 'turn_1', + callId: 'call_1', + status: 'cancellation_requested' + }) + expect(cancellation.cancel).toHaveBeenCalledWith({ + threadId: 'thread_1', + turnId: 'turn_1', + callId: 'call_1' + }) + }) + + it('maps missing and inactive calls to the documented HTTP statuses', async () => { + const notFound = await cancelToolCall({ + cancel: async () => { throw new Error('tool call not found: call_1') } + } as never, 'thread_1', 'turn_1', 'call_1') as JsonResponse + expect(notFound.status).toBe(404) + + const conflict = await cancelToolCall({ + cancel: async () => { throw new Error('tool call is no longer active: call_1') } + } as never, 'thread_1', 'turn_1', 'call_1') as JsonResponse + expect(conflict.status).toBe(409) + }) +}) + describe('POST /v1/threads/:id/turns admission', () => { it('rejects stale Graph submissions after safe disable while direct turns remain available', async () => { const threadStore = new InMemoryThreadStore() diff --git a/kun/src/server/routes/turns.ts b/kun/src/server/routes/turns.ts index 132afeecc..fae3553b4 100644 --- a/kun/src/server/routes/turns.ts +++ b/kun/src/server/routes/turns.ts @@ -1,5 +1,6 @@ import { CompactRequest, + CancelToolCallResponse, InterruptTurnRequest, InterruptTurnResponse, RewindThreadRequest, @@ -17,6 +18,7 @@ import { ERRORS } from './runtime-error.js' import { TurnCapacityError, TurnConflictError, type TurnService } from '../../services/turn-service.js' import { ThreadExecutionBusyError } from '../../ports/thread-execution-lease.js' import { isPublicTurnItem } from '../../contracts/items.js' +import type { ToolCancellationService } from '../../services/tool-cancellation-service.js' export async function startTurn( turns: TurnService, @@ -155,6 +157,27 @@ export async function interruptTurn( return jsonResponse(payload) } +export async function cancelToolCall( + cancellation: ToolCancellationService | undefined, + threadId: string, + turnId: string, + callId: string +): Promise { + if (!cancellation) return ERRORS.unavailable('tool cancellation is unavailable') + try { + const result = await cancellation.cancel({ threadId, turnId, callId }) + const payload: CancelToolCallResponse = CancelToolCallResponse.parse(result) + return jsonResponse(payload) + } catch (error) { + if (error instanceof TurnConflictError) return ERRORS.conflict(error.message) + if (error instanceof Error && /not found/i.test(error.message)) return ERRORS.notFound(error.message) + if (error instanceof Error && /no longer active|not currently executing|already being interrupted/i.test(error.message)) { + return ERRORS.conflict(error.message) + } + throw error + } +} + export async function compactTurn( turns: TurnService, threadId: string, diff --git a/kun/src/server/runtime-factory.ts b/kun/src/server/runtime-factory.ts index f3768a886..f9c8c21fd 100644 --- a/kun/src/server/runtime-factory.ts +++ b/kun/src/server/runtime-factory.ts @@ -104,6 +104,7 @@ import { } from '../contracts/policy.js' import { AgentLoop, type AgentLoopOptions } from '../loop/agent-loop.js' import { ContextCompactor } from '../loop/context-compactor.js' +import { withModelTiming } from '../loop/model-timing-decorator.js' import type { TokenEconomyConfig } from '../loop/token-economy.js' import { DEFAULT_CONTEXT_THRESHOLDS, @@ -138,6 +139,7 @@ import { buildBuiltinHooks } from '../hooks/builtins/index.js' import { mergeBuiltinSubagentProfiles } from '../delegation/builtin-profiles.js' import { buildExploreAgentToolProvider } from '../adapters/tool/explore-agent-tool-provider.js' import { InflightTracker } from '../loop/inflight-tracker.js' +import { ToolCancellationRegistry } from '../loop/tool-cancellation-registry.js' import { SteeringQueue } from '../loop/steering-queue.js' import type { TurnRunOutcome } from '../loop/turn-execution-types.js' import { RandomIdGenerator } from '../ports/id-generator.js' @@ -148,6 +150,7 @@ import type { ToolHostContext } from '../ports/tool-host.js' import { ScopedMigrationMaintenanceLock } from '../ports/migration-maintenance-lock.js' import { KUN_SYSTEM_PROMPT } from '../prompt/kun-system-prompt.js' import { RuntimeEventRecorder } from '../services/runtime-event-recorder.js' +import { ToolCancellationService } from '../services/tool-cancellation-service.js' import { GraphRuntimeComposition } from './graph-runtime-factory.js' import { createGraphRuntimeStartOptions } from './graph-runtime-bootstrap.js' import { @@ -411,6 +414,7 @@ async function createKunServeRuntimeComposition( const workspaceInspector = new LocalWorkspaceInspector() const usageService = new UsageService() const inflight = new InflightTracker() + const toolCancellation = new ToolCancellationRegistry() const steering = new SteeringQueue() let modelProfiles = modelContextProfilesFromConfig({ contextCompaction: activeOptions.contextCompaction, @@ -772,17 +776,23 @@ async function createKunServeRuntimeComposition( modelCapabilities, routeHealth ) + /** + * Timing-instrumented entry point shared by the chat loop, child agents, + * review, and compaction so every model response reports TTFT and + * generation duration on its usage chunk. + */ + const timedModelClient = withModelTiming(modelClient) const routePoolTests = new RoutePoolTestService( modelClient, () => modelClient.routePools(), routeHealth ) const subagentRouter = new SubagentRouter({ - modelClient, + modelClient: timedModelClient, roles: () => activeOptions.roles, defaultModel: () => activeOptions.model, recordUsage: async ({ threadId, turnId, model, usage }) => { - const cumulative = usageService.record(threadId, usage) + const cumulative = usageService.record(threadId, usage, undefined, turnId) await events.record({ kind: 'usage', threadId, @@ -1025,7 +1035,7 @@ async function createKunServeRuntimeComposition( inflight, steering, compactor, - model: modelClient, + model: timedModelClient, usage: usageService, prefix, attachmentStore: () => attachmentStore, @@ -1041,9 +1051,7 @@ async function createKunServeRuntimeComposition( transitionGraphPlanningDraft: (input) => graphRuntime.transitionPlanningDraft(input), cancelGraphSourceRuns: ({ threadId, sourceTurnId }) => - graphRuntime.handleSourceTurnTerminal(threadId, sourceTurnId, 'aborted', { - forceCancel: true - }), + graphRuntime.cancelSourceTurnRunsExplicitly(threadId, sourceTurnId), migrationMaintenance, ids, nowIso @@ -1075,6 +1083,11 @@ async function createKunServeRuntimeComposition( turns: turnService, nowIso }) + const toolCancellationService = new ToolCancellationService( + turnService, + toolCancellation, + nowIso + ) const supplyChainTrust = new InMemoryPublisherTrustStore() backgroundShellRuntime.bindStopHandler(stopBashSessionById) const backgroundShellTool = createBackgroundShellTool({ @@ -1100,7 +1113,7 @@ async function createKunServeRuntimeComposition( const reviewDeps = { threadStore, turns: turnService, - model: modelClient, + model: timedModelClient, defaultModel: activeOptions.model, nowIso, modelCapabilities, @@ -1427,7 +1440,7 @@ async function createKunServeRuntimeComposition( turns: turnService, nowIso, executor: createChildAgentExecutor({ - model: modelClient, + model: timedModelClient, toolHost: childToolHost, prefix, defaultModel: activeOptions.model, @@ -1733,13 +1746,14 @@ async function createKunServeRuntimeComposition( approvalGate, approvalReview: approvalReviewService, userInputGate, - model: modelClient, + model: timedModelClient, toolHost, sdkRuntime, usage: usageService, events, turns: turnService, inflight, + toolCancellation, steering, compactor, prefix, @@ -2473,6 +2487,10 @@ async function createKunServeRuntimeComposition( tools: [taskGraphTool] }, ...buildDelegationToolProviders(delegationRuntime, subagentRouter), + ...buildExploreAgentToolProvider( + delegationRuntime, + () => activeOptions.lab?.exploreAgent + ), ...buildComponentDesignToolProviders(delegationRuntime) ]) @@ -2580,7 +2598,7 @@ async function createKunServeRuntimeComposition( turnService.updateRuntimeConfig({ defaultModel: activeOptions.model, contextCompaction: activeOptions.contextCompaction, - model: modelClient, + model: timedModelClient, maxConcurrentTurns: activeOptions.runtime?.turnLimits?.maxConcurrentTurns }) extensionAgent.updateRuntimeConfig({ @@ -2634,9 +2652,10 @@ async function createKunServeRuntimeComposition( } } } - return { + return { threadService, turnService, + toolCancellationService, reviewService, usageService, eventBus, diff --git a/kun/src/services/chromium-browser-cookies.test.ts b/kun/src/services/chromium-browser-cookies.test.ts new file mode 100644 index 000000000..49656c05e --- /dev/null +++ b/kun/src/services/chromium-browser-cookies.test.ts @@ -0,0 +1,292 @@ +import { createCipheriv, createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + decryptChromiumCookieValue, + deriveChromiumSafeStorageKey, + listChromiumCookieDatabaseCandidates, + readChromiumCookiesForDomains, + readChromiumCookiesForDomainsWithDiagnosis +} from './chromium-browser-cookies.js' + +describe('chromium-browser-cookies', () => { + it('lists Comet cookie databases and Chrome Beta/Canary on macOS', () => { + const paths = listChromiumCookieDatabaseCandidates({ + platform: 'darwin', + homeDirectory: '/Users/kun', + environment: {} + }).map((candidate) => candidate.databasePath) + + expect(paths).toEqual(expect.arrayContaining([ + '/Users/kun/Library/Application Support/Google/Chrome/Default/Network/Cookies', + '/Users/kun/Library/Application Support/Google/Chrome/Default/Cookies', + '/Users/kun/Library/Application Support/Google/Chrome Beta/Default/Network/Cookies', + '/Users/kun/Library/Application Support/Google/Chrome Canary/Default/Network/Cookies', + '/Users/kun/Library/Application Support/Comet/Default/Network/Cookies', + '/Users/kun/Library/Application Support/Comet/Default/Cookies', + '/Users/kun/Library/Application Support/Dia/User Data/Default/Cookies' + ])) + }) + + it('skips missing cookie databases when a profile root exists', () => { + const home = mkdtempSync(join(tmpdir(), 'kun-chromium-home-')) + const profileRoot = join(home, 'Library', 'Application Support', 'Google', 'Chrome') + const defaultProfile = join(profileRoot, 'Default') + mkdirSync(join(defaultProfile, 'Network'), { recursive: true }) + writeFileSync(join(defaultProfile, 'Cookies'), '') + + const paths = listChromiumCookieDatabaseCandidates({ + platform: 'darwin', + homeDirectory: home, + environment: {}, + browsers: [{ + id: 'chrome', + displayName: 'Chrome', + profileRootSegments: ['Google', 'Chrome'], + safeStorageLabels: [{ service: 'Chrome Safe Storage', account: 'Chrome' }] + }] + }).map((candidate) => candidate.databasePath) + + expect(paths).toEqual([join(defaultProfile, 'Cookies')]) + expect(paths.some((path) => path.includes(`${join('Network', 'Cookies')}`))).toBe(false) + }) + + it('decrypts v10 cookies with DB version >= 24 domain hash prefix', () => { + const password = 'test-password' + const key = deriveChromiumSafeStorageKey(password) + const hostKey = 'opencode.ai' + const plaintext = 'session-token-value' + const encrypted = encryptV10Cookie(plaintext, key, hostKey, 24) + + expect(decryptChromiumCookieValue(encrypted, key, hostKey, 24)).toBe(plaintext) + expect( + decryptChromiumCookieValue(encrypted, key, 'other.host', 24) + ).toBeUndefined() + }) + + it('reads and decrypts OpenCode auth cookies from a Chromium DB', async () => { + const directory = mkdtempSync(join(tmpdir(), 'kun-chromium-cookie-')) + const databasePath = join(directory, 'Cookies') + const password = 'comet-password' + const key = deriveChromiumSafeStorageKey(password) + const hostKey = 'opencode.ai' + const token = 'auth-session-token' + const encrypted = encryptV10Cookie(token, key, hostKey, 24) + const localeEncrypted = encryptV10Cookie('en', key, hostKey, 24) + createCookieDatabase(databasePath, [ + { hostKey, name: 'auth', value: '', encryptedHex: encrypted.toString('hex') }, + { hostKey, name: 'oc_locale', value: '', encryptedHex: localeEncrypted.toString('hex') } + ]) + + const cookies = await readChromiumCookiesForDomains({ + platform: 'darwin', + candidates: [{ + browser: { + id: 'comet', + displayName: 'Comet', + profileRootSegments: ['Comet'], + safeStorageLabels: [{ service: 'Comet Safe Storage', account: 'Comet' }] + }, + databasePath + }], + domainSuffixes: ['opencode.ai', 'app.opencode.ai'], + cookieNames: new Set(['auth', '__host-auth']), + readSafeStoragePassword: async () => password + }) + + expect(cookies).toEqual([ + { name: 'auth', value: token, hostKey: 'opencode.ai' } + ]) + }) + + it('reads auth cookies hosted on app.opencode.ai', async () => { + const directory = mkdtempSync(join(tmpdir(), 'kun-chromium-cookie-app-')) + const databasePath = join(directory, 'Cookies') + createCookieDatabase(databasePath, [ + { hostKey: 'app.opencode.ai', name: 'auth', value: 'app-token', encryptedHex: '' } + ]) + + const result = await readChromiumCookiesForDomainsWithDiagnosis({ + platform: 'darwin', + candidates: [{ + browser: { + id: 'chrome', + displayName: 'Chrome', + profileRootSegments: ['Google', 'Chrome'], + safeStorageLabels: [{ service: 'Chrome Safe Storage', account: 'Chrome' }] + }, + databasePath + }], + domainSuffixes: ['opencode.ai', 'app.opencode.ai'], + cookieNames: new Set(['auth']) + }) + + expect(result.cookies).toEqual([ + { name: 'auth', value: 'app-token', hostKey: 'app.opencode.ai' } + ]) + expect(result.diagnosis.kind).toBe('success') + }) + + it('prefers plaintext cookie values without touching Safe Storage', async () => { + const directory = mkdtempSync(join(tmpdir(), 'kun-chromium-cookie-plain-')) + const databasePath = join(directory, 'Cookies') + createCookieDatabase(databasePath, [ + { hostKey: 'opencode.ai', name: 'auth', value: 'plain-token', encryptedHex: '' } + ]) + + let passwordCalls = 0 + const cookies = await readChromiumCookiesForDomains({ + platform: 'darwin', + candidates: [{ + browser: { + id: 'chrome', + displayName: 'Chrome', + profileRootSegments: ['Google', 'Chrome'], + safeStorageLabels: [{ service: 'Chrome Safe Storage', account: 'Chrome' }] + }, + databasePath + }], + cookieNames: new Set(['auth']), + readSafeStoragePassword: async () => { + passwordCalls += 1 + return 'unused' + } + }) + + expect(cookies).toEqual([ + { name: 'auth', value: 'plain-token', hostKey: 'opencode.ai' } + ]) + expect(passwordCalls).toBe(0) + }) + + it('reports decrypt_failed when encrypted auth exists but Keychain is unavailable', async () => { + const directory = mkdtempSync(join(tmpdir(), 'kun-chromium-cookie-locked-')) + const databasePath = join(directory, 'Cookies') + const key = deriveChromiumSafeStorageKey('secret') + const encrypted = encryptV10Cookie('token', key, 'opencode.ai', 24) + createCookieDatabase(databasePath, [ + { hostKey: 'opencode.ai', name: 'auth', value: '', encryptedHex: encrypted.toString('hex') } + ]) + + const result = await readChromiumCookiesForDomainsWithDiagnosis({ + platform: 'darwin', + candidates: [{ + browser: { + id: 'chrome', + displayName: 'Chrome', + profileRootSegments: ['Google', 'Chrome'], + safeStorageLabels: [{ service: 'Chrome Safe Storage', account: 'Chrome' }] + }, + databasePath + }], + cookieNames: new Set(['auth']), + readSafeStoragePassword: async () => undefined + }) + + expect(result.cookies).toEqual([]) + expect(result.diagnosis).toMatchObject({ + kind: 'decrypt_failed', + browserId: 'chrome', + reason: 'keychain_unavailable' + }) + }) + + it('caches Safe Storage passwords across profiles in one scan', async () => { + const directory = mkdtempSync(join(tmpdir(), 'kun-chromium-cookie-cache-')) + const firstPath = join(directory, 'one', 'Cookies') + const secondPath = join(directory, 'two', 'Cookies') + mkdirSync(join(directory, 'one'), { recursive: true }) + mkdirSync(join(directory, 'two'), { recursive: true }) + const password = 'shared-password' + const key = deriveChromiumSafeStorageKey(password) + // First profile's blob uses a mismatched domain hash so decrypt fails after + // Keychain is consulted; the second profile should reuse the cached password. + createCookieDatabase(firstPath, [ + { + hostKey: 'opencode.ai', + name: 'auth', + value: '', + encryptedHex: encryptV10Cookie('stale', key, 'other.host', 24).toString('hex') + } + ]) + createCookieDatabase(secondPath, [ + { + hostKey: 'opencode.ai', + name: 'auth', + value: '', + encryptedHex: encryptV10Cookie('cached-token', key, 'opencode.ai', 24).toString('hex') + } + ]) + + let passwordCalls = 0 + const browser = { + id: 'chrome', + displayName: 'Chrome', + profileRootSegments: ['Google', 'Chrome'], + safeStorageLabels: [{ service: 'Chrome Safe Storage', account: 'Chrome' }] + } + const result = await readChromiumCookiesForDomainsWithDiagnosis({ + platform: 'darwin', + candidates: [ + { browser, databasePath: firstPath }, + { browser, databasePath: secondPath } + ], + cookieNames: new Set(['auth']), + readSafeStoragePassword: async () => { + passwordCalls += 1 + return password + } + }) + + expect(result.cookies).toEqual([ + { name: 'auth', value: 'cached-token', hostKey: 'opencode.ai' } + ]) + expect(passwordCalls).toBe(1) + }) +}) + +function createCookieDatabase( + databasePath: string, + rows: Array<{ hostKey: string; name: string; value: string; encryptedHex: string }> +): void { + const binary = process.platform === 'darwin' ? '/usr/bin/sqlite3' : 'sqlite3' + const inserts = rows.map((row) => { + const encryptedSql = row.encryptedHex ? `X'${row.encryptedHex}'` : `X''` + return `INSERT INTO cookies (host_key, name, value, encrypted_value) VALUES ('${row.hostKey}', '${row.name}', '${row.value}', ${encryptedSql});` + }).join('\n') + execFileSync(binary, [databasePath], { + input: ` + CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT); + CREATE TABLE cookies ( + host_key TEXT, + name TEXT, + value TEXT, + encrypted_value BLOB + ); + INSERT INTO meta (key, value) VALUES ('version', '24'); + ${inserts} + `, + encoding: 'utf8' + }) +} + +function encryptV10Cookie( + plaintext: string, + key: Buffer, + hostKey: string, + databaseVersion: number +): Buffer { + const body = databaseVersion >= 24 + ? Buffer.concat([ + createHash('sha256').update(hostKey, 'utf8').digest(), + Buffer.from(plaintext, 'utf8') + ]) + : Buffer.from(plaintext, 'utf8') + const iv = Buffer.alloc(16, 0x20) + const cipher = createCipheriv('aes-128-cbc', key, iv) + const encrypted = Buffer.concat([cipher.update(body), cipher.final()]) + return Buffer.concat([Buffer.from('v10', 'utf8'), encrypted]) +} diff --git a/kun/src/services/chromium-browser-cookies.ts b/kun/src/services/chromium-browser-cookies.ts new file mode 100644 index 000000000..9ab30c4ac --- /dev/null +++ b/kun/src/services/chromium-browser-cookies.ts @@ -0,0 +1,674 @@ +import { createDecipheriv, createHash, pbkdf2Sync } from 'node:crypto' +import { execFile } from 'node:child_process' +import { accessSync, constants, readdirSync } from 'node:fs' +import { access, copyFile, mkdtemp, rm } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' +import { join, win32 } from 'node:path' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) +const CHROMIUM_COOKIE_SALT = Buffer.from('saltysalt') +const CHROMIUM_COOKIE_IV = Buffer.alloc(16, 0x20) +/** Allow time for the macOS Keychain Allow dialog on first access. */ +const KEYCHAIN_TIMEOUT_MS = 30_000 + +export type ChromiumCookieRow = { + name: string + value: string + hostKey: string +} + +export type ChromiumSafeStorageLabel = { + service: string + account: string +} + +export type ChromiumBrowserCookieSource = { + id: string + displayName: string + /** Application Support-relative profile root on macOS / Linux config root. */ + profileRootSegments: string[] + /** Windows Local AppData-relative profile root. */ + windowsProfileRootSegments?: string[] + /** Linux ~/.config-relative profile root segments. */ + linuxProfileRootSegments?: string[] + safeStorageLabels: ChromiumSafeStorageLabel[] +} + +/** + * Chromium browsers CodexBar / SweetCookieKit can import from for OpenCode Go. + * Keep Comet and Dia here — they are real session hosts for opencode.ai. + * Beta/Canary/Nightly variants match CodexBar's broader Chromium coverage. + */ +export const OPENCODE_GO_CHROMIUM_BROWSERS: ChromiumBrowserCookieSource[] = [ + { + id: 'chrome', + displayName: 'Chrome', + profileRootSegments: ['Google', 'Chrome'], + windowsProfileRootSegments: ['Google', 'Chrome', 'User Data'], + linuxProfileRootSegments: ['google-chrome'], + safeStorageLabels: [{ service: 'Chrome Safe Storage', account: 'Chrome' }] + }, + { + id: 'chrome-beta', + displayName: 'Chrome Beta', + profileRootSegments: ['Google', 'Chrome Beta'], + windowsProfileRootSegments: ['Google', 'Chrome Beta', 'User Data'], + linuxProfileRootSegments: ['google-chrome-beta'], + safeStorageLabels: [ + { service: 'Chrome Safe Storage', account: 'Chrome' }, + { service: 'Chrome Beta Safe Storage', account: 'Chrome Beta' } + ] + }, + { + id: 'chrome-canary', + displayName: 'Chrome Canary', + profileRootSegments: ['Google', 'Chrome Canary'], + windowsProfileRootSegments: ['Google', 'Chrome SxS', 'User Data'], + linuxProfileRootSegments: ['google-chrome-unstable'], + safeStorageLabels: [ + { service: 'Chrome Safe Storage', account: 'Chrome' }, + { service: 'Chrome Canary Safe Storage', account: 'Chrome Canary' } + ] + }, + { + id: 'edge', + displayName: 'Microsoft Edge', + profileRootSegments: ['Microsoft Edge'], + windowsProfileRootSegments: ['Microsoft', 'Edge', 'User Data'], + linuxProfileRootSegments: ['microsoft-edge'], + safeStorageLabels: [ + { service: 'Microsoft Edge Safe Storage', account: 'Microsoft Edge' } + ] + }, + { + id: 'edge-beta', + displayName: 'Microsoft Edge Beta', + profileRootSegments: ['Microsoft Edge Beta'], + windowsProfileRootSegments: ['Microsoft', 'Edge Beta', 'User Data'], + linuxProfileRootSegments: ['microsoft-edge-beta'], + safeStorageLabels: [ + { service: 'Microsoft Edge Safe Storage', account: 'Microsoft Edge' }, + { service: 'Microsoft Edge Beta Safe Storage', account: 'Microsoft Edge Beta' } + ] + }, + { + id: 'edge-canary', + displayName: 'Microsoft Edge Canary', + profileRootSegments: ['Microsoft Edge Canary'], + windowsProfileRootSegments: ['Microsoft', 'Edge SxS', 'User Data'], + linuxProfileRootSegments: ['microsoft-edge-dev'], + safeStorageLabels: [ + { service: 'Microsoft Edge Safe Storage', account: 'Microsoft Edge' }, + { service: 'Microsoft Edge Canary Safe Storage', account: 'Microsoft Edge Canary' } + ] + }, + { + id: 'brave', + displayName: 'Brave', + profileRootSegments: ['BraveSoftware', 'Brave-Browser'], + windowsProfileRootSegments: ['BraveSoftware', 'Brave-Browser', 'User Data'], + linuxProfileRootSegments: ['BraveSoftware', 'Brave-Browser'], + safeStorageLabels: [{ service: 'Brave Safe Storage', account: 'Brave' }] + }, + { + id: 'brave-beta', + displayName: 'Brave Beta', + profileRootSegments: ['BraveSoftware', 'Brave-Browser-Beta'], + windowsProfileRootSegments: ['BraveSoftware', 'Brave-Browser-Beta', 'User Data'], + linuxProfileRootSegments: ['BraveSoftware', 'Brave-Browser-Beta'], + safeStorageLabels: [ + { service: 'Brave Safe Storage', account: 'Brave' }, + { service: 'Brave Safe Storage', account: 'Brave Beta' } + ] + }, + { + id: 'brave-nightly', + displayName: 'Brave Nightly', + profileRootSegments: ['BraveSoftware', 'Brave-Browser-Nightly'], + windowsProfileRootSegments: ['BraveSoftware', 'Brave-Browser-Nightly', 'User Data'], + linuxProfileRootSegments: ['BraveSoftware', 'Brave-Browser-Nightly'], + safeStorageLabels: [ + { service: 'Brave Safe Storage', account: 'Brave' }, + { service: 'Brave Safe Storage', account: 'Brave Nightly' } + ] + }, + { + id: 'arc', + displayName: 'Arc', + profileRootSegments: ['Arc', 'User Data'], + linuxProfileRootSegments: ['arc'], + safeStorageLabels: [{ service: 'Arc Safe Storage', account: 'Arc' }] + }, + { + id: 'dia', + displayName: 'Dia', + profileRootSegments: ['Dia', 'User Data'], + safeStorageLabels: [{ service: 'Dia Safe Storage', account: 'Dia' }] + }, + { + id: 'comet', + displayName: 'Comet', + profileRootSegments: ['Comet'], + linuxProfileRootSegments: ['comet'], + safeStorageLabels: [{ service: 'Comet Safe Storage', account: 'Comet' }] + }, + { + id: 'vivaldi', + displayName: 'Vivaldi', + profileRootSegments: ['Vivaldi'], + windowsProfileRootSegments: ['Vivaldi', 'User Data'], + linuxProfileRootSegments: ['vivaldi'], + safeStorageLabels: [{ service: 'Vivaldi Safe Storage', account: 'Vivaldi' }] + }, + { + id: 'chromium', + displayName: 'Chromium', + profileRootSegments: ['Chromium'], + windowsProfileRootSegments: ['Chromium', 'User Data'], + linuxProfileRootSegments: ['chromium'], + safeStorageLabels: [{ service: 'Chromium Safe Storage', account: 'Chromium' }] + } +] + +export type ChromiumCookieDatabaseCandidate = { + browser: ChromiumBrowserCookieSource + databasePath: string +} + +/** Non-sensitive outcome of a Chromium cookie scan (never includes cookie values). */ +export type ChromiumCookieReadDiagnosis = + | { + kind: 'success' + browserId: string + browserDisplayName: string + databasePath: string + } + | { + kind: 'not_found' + scannedDatabases: number + foundAuthRows: boolean + } + | { + kind: 'decrypt_failed' + browserId: string + browserDisplayName: string + databasePath: string + reason: 'keychain_unavailable' | 'decrypt_failed' + } + +export type ChromiumCookieReadResult = { + cookies: ChromiumCookieRow[] + diagnosis: ChromiumCookieReadDiagnosis +} + +export type ReadChromiumCookiesForDomainsOptions = { + platform?: NodeJS.Platform + environment?: NodeJS.ProcessEnv + homeDirectory?: string + browsers?: ChromiumBrowserCookieSource[] + candidates?: ChromiumCookieDatabaseCandidate[] + domainSuffixes?: string[] + cookieNames?: Set + readSafeStoragePassword?: ( + label: ChromiumSafeStorageLabel + ) => Promise +} + +/** + * Reads Chromium cookies for the given domains, decrypting macOS Safe Storage + * values when needed. Mirrors SweetCookieKit's best-effort Chromium path: + * copy the locked Cookies DB, decrypt v10 blobs with PBKDF2+AES-CBC, and for + * cookie DB version >= 24 strip the SHA-256(host_key) prefix. + */ +export async function readChromiumCookiesForDomains( + options: ReadChromiumCookiesForDomainsOptions = {} +): Promise { + const result = await readChromiumCookiesForDomainsWithDiagnosis(options) + return result.cookies +} + +/** + * Same as {@link readChromiumCookiesForDomains}, but also returns a + * non-sensitive diagnosis so callers can distinguish "not signed in" from + * "signed in but Keychain/decrypt failed". + */ +export async function readChromiumCookiesForDomainsWithDiagnosis( + options: ReadChromiumCookiesForDomainsOptions = {} +): Promise { + const domainSuffixes = options.domainSuffixes ?? ['opencode.ai', 'app.opencode.ai'] + const cookieNames = options.cookieNames + const candidates = options.candidates ?? + listChromiumCookieDatabaseCandidates(options) + const passwordCache = new Map() + let scannedDatabases = 0 + let foundAuthRows = false + let decryptFailure: Extract | undefined + + for (const candidate of candidates) { + try { + if (!(await cookieDatabaseExists(candidate.databasePath))) continue + scannedDatabases += 1 + const rows = await readCookiesFromDatabase(candidate.databasePath, domainSuffixes) + const matched = rows.filter((row) => + cookieNames ? cookieNames.has(row.name.toLowerCase()) : true + ) + if (matched.length === 0) continue + foundAuthRows = true + + const plaintext = matched.filter((row) => row.value.trim().length > 0) + if (plaintext.length > 0) { + return { + cookies: plaintext.map((row) => ({ + name: row.name, + value: row.value, + hostKey: row.hostKey + })), + diagnosis: { + kind: 'success', + browserId: candidate.browser.id, + browserDisplayName: candidate.browser.displayName, + databasePath: candidate.databasePath + } + } + } + + const encrypted = matched.filter((row) => row.encryptedValue.length > 0) + if (encrypted.length === 0) continue + const platform = options.platform ?? process.platform + if (platform !== 'darwin') { + decryptFailure = { + kind: 'decrypt_failed', + browserId: candidate.browser.id, + browserDisplayName: candidate.browser.displayName, + databasePath: candidate.databasePath, + reason: 'decrypt_failed' + } + continue + } + + const password = await resolveSafeStoragePassword( + candidate.browser, + options.readSafeStoragePassword, + passwordCache + ) + if (!password) { + decryptFailure = { + kind: 'decrypt_failed', + browserId: candidate.browser.id, + browserDisplayName: candidate.browser.displayName, + databasePath: candidate.databasePath, + reason: 'keychain_unavailable' + } + continue + } + const key = deriveChromiumSafeStorageKey(password) + const decrypted: ChromiumCookieRow[] = [] + for (const row of encrypted) { + const value = decryptChromiumCookieValue( + row.encryptedValue, + key, + row.hostKey, + row.databaseVersion + ) + if (!value?.trim()) continue + decrypted.push({ name: row.name, value, hostKey: row.hostKey }) + } + if (decrypted.length > 0) { + return { + cookies: decrypted, + diagnosis: { + kind: 'success', + browserId: candidate.browser.id, + browserDisplayName: candidate.browser.displayName, + databasePath: candidate.databasePath + } + } + } + decryptFailure = { + kind: 'decrypt_failed', + browserId: candidate.browser.id, + browserDisplayName: candidate.browser.displayName, + databasePath: candidate.databasePath, + reason: 'decrypt_failed' + } + } catch { + // Locked DBs and unexpected IO failures are expected; try the next source. + } + } + + if (decryptFailure) { + return { cookies: [], diagnosis: decryptFailure } + } + return { + cookies: [], + diagnosis: { + kind: 'not_found', + scannedDatabases, + foundAuthRows + } + } +} + +export function listChromiumCookieDatabaseCandidates( + options: Omit< + ReadChromiumCookiesForDomainsOptions, + 'candidates' | 'domainSuffixes' | 'cookieNames' | 'readSafeStoragePassword' + > = {} +): ChromiumCookieDatabaseCandidate[] { + const platform = options.platform ?? process.platform + const environment = options.environment ?? process.env + const userHome = options.homeDirectory ?? homedir() + const browsers = options.browsers ?? OPENCODE_GO_CHROMIUM_BROWSERS + const joinPath = platform === 'win32' ? win32.join : join + const roots: Array<{ browser: ChromiumBrowserCookieSource; root: string }> = [] + + for (const browser of browsers) { + if (platform === 'darwin') { + roots.push({ + browser, + root: joinPath(userHome, 'Library', 'Application Support', ...browser.profileRootSegments) + }) + continue + } + if (platform === 'linux') { + const segments = browser.linuxProfileRootSegments + if (!segments?.length) continue + roots.push({ + browser, + root: joinPath(userHome, '.config', ...segments) + }) + continue + } + if (platform === 'win32') { + const segments = browser.windowsProfileRootSegments + if (!segments) continue + const localAppData = environment.LOCALAPPDATA?.trim() + const localRoot = localAppData || joinPath(userHome, 'AppData', 'Local') + roots.push({ + browser, + root: joinPath(localRoot, ...segments) + }) + } + } + + const out: ChromiumCookieDatabaseCandidate[] = [] + for (const { browser, root } of roots) { + for (const profileName of discoverChromiumProfileNamesSync(root)) { + const networkPath = joinPath(root, profileName, 'Network', 'Cookies') + const legacyPath = joinPath(root, profileName, 'Cookies') + // Prefer listing only paths that exist when the profile root is readable. + // When the root itself is missing, keep Default candidates for tests that + // assert path shapes without creating directories. + if (directoryExistsSync(root)) { + if (cookieDatabaseExistsSync(networkPath)) { + out.push({ browser, databasePath: networkPath }) + } + if (cookieDatabaseExistsSync(legacyPath)) { + out.push({ browser, databasePath: legacyPath }) + } + continue + } + out.push( + { browser, databasePath: networkPath }, + { browser, databasePath: legacyPath } + ) + } + } + return out +} + +/** Exported for tests: PBKDF2 key derivation used by Chromium Safe Storage. */ +export function deriveChromiumSafeStorageKey(password: string): Buffer { + return pbkdf2Sync(password, CHROMIUM_COOKIE_SALT, 1_003, 16, 'sha1') +} + +/** Exported for tests: decrypt a Chromium v10 cookie blob. */ +export function decryptChromiumCookieValue( + encryptedValue: Buffer, + key: Buffer, + hostKey: string, + databaseVersion: number +): string | undefined { + if (encryptedValue.length <= 3) return undefined + const prefix = encryptedValue.subarray(0, 3).toString('utf8') + if (prefix !== 'v10') return undefined + const payload = encryptedValue.subarray(3) + if (payload.length === 0 || payload.length % 16 !== 0) return undefined + let decrypted: Buffer + try { + const decipher = createDecipheriv('aes-128-cbc', key, CHROMIUM_COOKIE_IV) + decrypted = Buffer.concat([decipher.update(payload), decipher.final()]) + } catch { + return undefined + } + let value = decrypted + if (databaseVersion >= 24) { + const expectedDomainHash = createHash('sha256').update(hostKey, 'utf8').digest() + if ( + value.length < expectedDomainHash.length || + !value.subarray(0, expectedDomainHash.length).equals(expectedDomainHash) + ) { + return undefined + } + value = value.subarray(expectedDomainHash.length) + } + const text = value.toString('utf8') + return text.length > 0 ? text : undefined +} + +type RawCookieRow = { + name: string + value: string + hostKey: string + encryptedValue: Buffer + databaseVersion: number +} + +async function cookieDatabaseExists(databasePath: string): Promise { + try { + await access(databasePath) + return true + } catch { + return false + } +} + +function cookieDatabaseExistsSync(databasePath: string): boolean { + try { + accessSync(databasePath, constants.F_OK) + return true + } catch { + return false + } +} + +function directoryExistsSync(directoryPath: string): boolean { + try { + accessSync(directoryPath, constants.F_OK) + return true + } catch { + return false + } +} + +async function readCookiesFromDatabase( + databasePath: string, + domainSuffixes: string[] +): Promise { + const tempRoot = await mkdtemp(join(tmpdir(), 'kun-chromium-cookies-')) + const copiedDb = join(tempRoot, 'Cookies') + try { + await copyFile(databasePath, copiedDb) + await Promise.allSettled([ + copyFile(`${databasePath}-wal`, `${copiedDb}-wal`), + copyFile(`${databasePath}-shm`, `${copiedDb}-shm`) + ]) + + let sqliteModule: { default: typeof import('better-sqlite3') } + try { + sqliteModule = await import('better-sqlite3') + } catch { + return await readCookiesFromDatabaseWithSqliteCli(copiedDb, domainSuffixes) + } + + let database: import('better-sqlite3').Database + try { + database = new sqliteModule.default(copiedDb, { + readonly: true, + fileMustExist: true + }) + } catch { + // Native module ABI mismatches (system Node vs Electron) fall back to sqlite3 CLI. + return await readCookiesFromDatabaseWithSqliteCli(copiedDb, domainSuffixes) + } + try { + database.pragma('query_only = ON') + database.pragma('busy_timeout = 250') + const versionRow = database.prepare( + "SELECT value FROM meta WHERE key = 'version' LIMIT 1" + ).get() as { value?: string | number } | undefined + const databaseVersion = Number(versionRow?.value ?? 0) + const where = domainSuffixes + .map(() => 'host_key LIKE ?') + .join(' OR ') + const params = domainSuffixes.map((suffix) => `%${suffix}`) + const rows = database.prepare(` + SELECT host_key AS hostKey, name, value, encrypted_value AS encryptedValue + FROM cookies + WHERE ${where} + `).all(...params) as Array<{ + hostKey?: unknown + name?: unknown + value?: unknown + encryptedValue?: unknown + }> + return rows.flatMap((row) => { + const hostKey = typeof row.hostKey === 'string' ? row.hostKey : '' + const name = typeof row.name === 'string' ? row.name : '' + if (!hostKey || !name) return [] + const value = typeof row.value === 'string' ? row.value : '' + const encryptedValue = Buffer.isBuffer(row.encryptedValue) + ? row.encryptedValue + : row.encryptedValue instanceof Uint8Array + ? Buffer.from(row.encryptedValue) + : Buffer.alloc(0) + return [{ name, value, hostKey, encryptedValue, databaseVersion }] + }) + } finally { + database.close() + } + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } +} + +async function readCookiesFromDatabaseWithSqliteCli( + databasePath: string, + domainSuffixes: string[] +): Promise { + const binary = process.platform === 'darwin' ? '/usr/bin/sqlite3' : 'sqlite3' + const where = domainSuffixes + .map((suffix) => `host_key LIKE '%${suffix.replaceAll("'", "''")}%'`) + .join(' OR ') + const versionResult = await execFileAsync(binary, [ + databasePath, + "SELECT value FROM meta WHERE key='version' LIMIT 1;" + ], { + encoding: 'utf8', + timeout: 2_000, + maxBuffer: 64 * 1024 + }).catch(() => ({ stdout: '0' })) + const databaseVersion = Number(versionResult.stdout.trim() || 0) + const { stdout } = await execFileAsync(binary, [ + '-separator', + '\t', + databasePath, + `SELECT host_key, name, value, hex(encrypted_value) FROM cookies WHERE ${where};` + ], { + encoding: 'utf8', + timeout: 2_000, + maxBuffer: 512 * 1024 + }) + return stdout + .split('\n') + .flatMap((line) => { + if (!line.trim()) return [] + const [hostKey, name, value, encryptedHex = ''] = line.split('\t') + if (!hostKey || !name) return [] + return [{ + hostKey, + name, + value: value ?? '', + encryptedValue: encryptedHex + ? Buffer.from(encryptedHex, 'hex') + : Buffer.alloc(0), + databaseVersion + }] + }) +} + +async function resolveSafeStoragePassword( + browser: ChromiumBrowserCookieSource, + override: ((label: ChromiumSafeStorageLabel) => Promise) | undefined, + passwordCache: Map +): Promise { + for (const label of browser.safeStorageLabels) { + const cacheKey = `${label.service}\0${label.account}` + if (passwordCache.has(cacheKey)) { + const cached = passwordCache.get(cacheKey) + if (cached?.trim()) return cached.trim() + continue + } + const password = override + ? await override(label) + : await readMacosSafeStoragePassword(label) + const trimmed = password?.trim() || undefined + passwordCache.set(cacheKey, trimmed) + if (trimmed) return trimmed + } + return undefined +} + +async function readMacosSafeStoragePassword( + label: ChromiumSafeStorageLabel +): Promise { + try { + const { stdout } = await execFileAsync('security', [ + 'find-generic-password', + '-w', + '-s', + label.service, + '-a', + label.account + ], { + encoding: 'utf8', + timeout: KEYCHAIN_TIMEOUT_MS, + maxBuffer: 64 * 1024 + }) + const password = stdout.trim() + return password || undefined + } catch { + return undefined + } +} + +function discoverChromiumProfileNamesSync(root: string): string[] { + // Synchronous discovery keeps path listing pure for tests; IO failures just + // fall back to the Default profile, which matches the previous OpenCode Go behavior. + try { + const entries = readdirSync(root, { withFileTypes: true }) + const names = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((name) => + name === 'Default' || + name.startsWith('Profile ') || + name.startsWith('user-') + ) + .sort((left, right) => left.localeCompare(right)) + return names.length > 0 ? names : ['Default'] + } catch { + return ['Default'] + } +} diff --git a/kun/src/services/model-connection-registry.test.ts b/kun/src/services/model-connection-registry.test.ts index 0366dc522..bc7354a7f 100644 --- a/kun/src/services/model-connection-registry.test.ts +++ b/kun/src/services/model-connection-registry.test.ts @@ -214,7 +214,7 @@ describe('ModelConnectionRegistry', () => { expect(String(fetchMock.mock.calls[0]?.[0])).toBe(expectedUrl) }) - it('does not guess a models URL from a custom full inference endpoint', async () => { + it('returns configured models for a custom full inference endpoint without guessing a models URL', async () => { const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) const { value } = await registry() @@ -233,9 +233,10 @@ describe('ModelConnectionRegistry', () => { select: false }) - await expect(value.probe('custom-full-endpoint')).rejects.toThrow( - 'custom_endpoint does not define a models URL' - ) + await expect(value.probe('custom-full-endpoint')).resolves.toEqual({ + ok: true, + models: ['configured-model'] + }) expect(fetchMock).not.toHaveBeenCalled() await expect(value.snapshot()).resolves.toMatchObject({ providers: [expect.objectContaining({ @@ -245,6 +246,62 @@ describe('ModelConnectionRegistry', () => { }) }) + it('rejects custom_endpoint probe when no models are configured', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + const { value } = await registry() + await value.connect({ + expectedRevision: 0, + id: 'custom-empty-models', + name: 'Custom Empty Models', + kind: 'http', + authType: 'api-key', + baseUrl: 'https://gateway.example.test/inference/team-a/respond', + endpointFormat: 'custom_endpoint', + credential: 'registry-secret', + models: [], + probe: false, + select: false + }) + + await expect(value.probe('custom-empty-models')).rejects.toThrow( + 'custom_endpoint does not define a models URL' + ) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('probes Codex with configured models without requesting a models URL', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + const { value } = await registry() + await value.connect({ + expectedRevision: 0, + id: 'codex', + name: 'ChatGPT 订阅', + kind: 'http', + authType: 'oauth', + baseUrl: 'https://chatgpt.com/backend-api/codex/responses', + endpointFormat: 'custom_endpoint', + credential: JSON.stringify({ + kind: 'codex-oauth', + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() + 60_000, + accountId: 'account-1' + }), + models: ['gpt-5.5', 'gpt-5.4'], + selectedModel: 'gpt-5.5', + probe: false, + select: false + }) + + await expect(value.probe('codex')).resolves.toEqual({ + ok: true, + models: ['gpt-5.5', 'gpt-5.4'] + }) + expect(fetchMock).not.toHaveBeenCalled() + }) + it('probes Messages providers with the Registry credential and Anthropic headers', async () => { const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => new Response(JSON.stringify({ data: [{ id: 'claude-sonnet-4-5' }] @@ -1977,6 +2034,113 @@ describe('ModelConnectionRegistry', () => { expect(afterRestart.providers[0]?.models).toEqual(['keep-model']) }) + it('selects the first remaining model when a catalog removes the active model', async () => { + const { value } = await registry() + const connected = await value.connect({ + expectedRevision: 0, + id: 'catalog-owner', + name: 'Catalog Owner', + kind: 'http', + authType: 'api-key', + baseUrl: 'https://catalog.example/v1', + endpointFormat: 'chat_completions', + credential: 'secret', + models: ['model-a', 'model-b'], + selectedModel: 'model-a', + probe: false, + select: true + }) + + const patched = await value.patch('catalog-owner', { + expectedRevision: connected.revision, + models: ['model-b'] + }) + + expect(patched.providers[0]).toMatchObject({ + models: ['model-b'], + selectedModel: 'model-b' + }) + expect(patched).toMatchObject({ + defaultProviderId: 'catalog-owner', + defaultAccountId: 'account:catalog-owner', + defaultModel: 'model-b' + }) + }) + + it('clears the default selection when the active provider loses its last model', async () => { + const { value } = await registry() + const connected = await value.connect({ + expectedRevision: 0, + id: 'catalog-owner', + name: 'Catalog Owner', + kind: 'http', + authType: 'api-key', + baseUrl: 'https://catalog.example/v1', + endpointFormat: 'chat_completions', + credential: 'secret', + models: ['model-a'], + selectedModel: 'model-a', + probe: false, + select: true + }) + + const patched = await value.patch('catalog-owner', { + expectedRevision: connected.revision, + models: [] + }) + + expect(patched.providers[0]).toMatchObject({ models: [] }) + expect(patched.providers[0]).not.toHaveProperty('selectedModel') + expect(patched).not.toHaveProperty('defaultProviderId') + expect(patched).not.toHaveProperty('defaultAccountId') + expect(patched).not.toHaveProperty('defaultModel') + }) + + it('falls back to another configured provider when the default provider loses its last model', async () => { + const { value } = await registry() + const primary = await value.connect({ + expectedRevision: 0, + id: 'primary', + name: 'Primary', + kind: 'http', + authType: 'api-key', + baseUrl: 'https://primary.example/v1', + endpointFormat: 'chat_completions', + credential: 'primary-secret', + models: ['primary-model'], + selectedModel: 'primary-model', + probe: false, + select: true + }) + const withFallback = await value.connect({ + expectedRevision: primary.revision, + id: 'fallback', + name: 'Fallback', + kind: 'http', + authType: 'api-key', + baseUrl: 'https://fallback.example/v1', + endpointFormat: 'chat_completions', + credential: 'fallback-secret', + models: ['fallback-model'], + selectedModel: 'fallback-model', + probe: false, + select: false + }) + + const patched = await value.patch('primary', { + expectedRevision: withFallback.revision, + models: [] + }) + + expect(patched.providers.find((provider) => provider.id === 'primary')) + .not.toHaveProperty('selectedModel') + expect(patched).toMatchObject({ + defaultProviderId: 'fallback', + defaultAccountId: 'account:fallback', + defaultModel: 'fallback-model' + }) + }) + it('retries deleted-provider legacy source retirement without allowing seed resurrection', async () => { let attempts = 0 const retired: string[] = [] diff --git a/kun/src/services/model-connection-registry.ts b/kun/src/services/model-connection-registry.ts index c6359b7c7..d187dffcc 100644 --- a/kun/src/services/model-connection-registry.ts +++ b/kun/src/services/model-connection-registry.ts @@ -706,6 +706,7 @@ export class ModelConnectionRegistry { async patch(providerId: string, raw: unknown): Promise { const input = ModelConnectionPatchRequestSchema.parse(raw) const { expectedRevision: _expectedRevision, ...changes } = input + const fallbackHealth = await this.inspectCredentialHealth(await this.file.read(emptyDocument)) const document = await this.file.update(emptyDocument, (current) => { assertRevision(current, input.expectedRevision, this.options.modelCapabilities, this.credentialHealth) const profile = requireProfile(current, providerId) @@ -721,24 +722,52 @@ export class ModelConnectionRegistry { : profile.modelCapabilities ? capabilitiesForModels(profile.modelCapabilities, models) : undefined - const selectedModel = input.selectedModel ?? profile.selectedModel - if (selectedModel && models.length > 0 && !models.includes(selectedModel)) { + if (input.selectedModel && !models.includes(input.selectedModel)) { throw new Error('selected model is not present in the provider model list') } + const selectedModel = input.selectedModel ?? ( + profile.selectedModel && models.includes(profile.selectedModel) + ? profile.selectedModel + : models[0] + ) + const { selectedModel: _previousSelectedModel, ...profileWithoutSelection } = profile + const nextProfile = StoredProfileSchema.parse({ + ...profileWithoutSelection, + ...changes, + models, + ...(modelCapabilities ? { modelCapabilities } : {}), + ...(selectedModel ? { selectedModel } : {}) + }) + const profiles = { + ...current.profiles, + [providerId]: nextProfile + } + const fallback = current.defaultProviderId === providerId && !selectedModel + ? configuredFallback(Object.values(profiles), fallbackHealth) + : undefined return { ...current, revision: current.revision + 1, - profiles: { - ...current.profiles, - [providerId]: StoredProfileSchema.parse({ - ...profile, - ...changes, - models, - ...(modelCapabilities ? { modelCapabilities } : {}), - selectedModel - }) - }, - ...(current.defaultProviderId === providerId && selectedModel ? { defaultModel: selectedModel } : {}) + profiles, + ...(current.defaultProviderId === providerId + ? selectedModel + ? { + defaultProviderId: providerId, + defaultAccountId: profile.accountId, + defaultModel: selectedModel + } + : fallback + ? { + defaultProviderId: fallback.profile.id, + defaultAccountId: fallback.profile.accountId, + defaultModel: fallback.model + } + : { + defaultProviderId: undefined, + defaultAccountId: undefined, + defaultModel: undefined + } + : {}) } }) await this.changed(document) @@ -2528,6 +2557,21 @@ async function probeModels(input: { }): Promise { if (input.kind !== 'http') return uniqueModels(input.fallbackModels) if (!input.baseUrl) throw new Error('provider probe failed: HTTP provider has no base URL') + // Custom full inference endpoints have no discoverable /models URL. When the + // profile already lists models (Codex, coding-plan gateways, user custom + // paths), treat an explicit credential + catalog as a successful probe. + if (input.endpointFormat === 'custom_endpoint') { + const configured = uniqueModels(input.fallbackModels) + if (configured.length === 0) { + throw new Error( + 'provider probe failed: custom_endpoint does not define a models URL; configure models explicitly with probe disabled' + ) + } + if (!input.apiKey.trim()) { + throw new Error('provider probe failed: custom_endpoint requires a credential when probing configured models') + } + return configured + } const url = modelsUrl(input.baseUrl, input.endpointFormat) const usesAnthropicHeaders = input.endpointFormat === 'messages' const authHeaders: Record = input.apiKey diff --git a/kun/src/services/opencode-go-web-quota.test.ts b/kun/src/services/opencode-go-web-quota.test.ts index a4e79b057..02d577638 100644 --- a/kun/src/services/opencode-go-web-quota.test.ts +++ b/kun/src/services/opencode-go-web-quota.test.ts @@ -28,6 +28,7 @@ describe('filterOpenCodeGoCookieHeader', () => { 'auth=a; __Host-auth=b; session=ignored; theme=dark' )).toBe('auth=a; __Host-auth=b') expect(filterOpenCodeGoCookieHeader('AUTH=x')).toBe('AUTH=x') + expect(filterOpenCodeGoCookieHeader('Cookie: auth=manual; session=x')).toBe('auth=manual') expect(filterOpenCodeGoCookieHeader('session=only')).toBeUndefined() expect(filterOpenCodeGoCookieHeader(undefined)).toBeUndefined() expect(filterOpenCodeGoCookieHeader('')).toBeUndefined() diff --git a/kun/src/services/opencode-go-web-quota.ts b/kun/src/services/opencode-go-web-quota.ts index be18b02cf..c763d9653 100644 --- a/kun/src/services/opencode-go-web-quota.ts +++ b/kun/src/services/opencode-go-web-quota.ts @@ -78,7 +78,8 @@ export function filterOpenCodeGoCookieHeader( rawHeader: string | undefined ): string | undefined { if (!rawHeader?.trim()) return undefined - const pairs = rawHeader + const normalized = rawHeader.trim().replace(/^cookie:\s*/i, '') + const pairs = normalized .split(';') .map((part) => part.trim()) .filter(Boolean) diff --git a/kun/src/services/provider-subscription-quota.test.ts b/kun/src/services/provider-subscription-quota.test.ts index 256e56cae..2d113c361 100644 --- a/kun/src/services/provider-subscription-quota.test.ts +++ b/kun/src/services/provider-subscription-quota.test.ts @@ -1,12 +1,26 @@ -import { describe, expect, it } from 'vitest' +import { createCipheriv, createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { deriveChromiumSafeStorageKey } from './chromium-browser-cookies.js' +import { OpenCodeGoWebQuotaError } from './opencode-go-web-quota.js' import { + clearOpenCodeGoCookieCache, + getOpenCodeGoCookieFailureReason, + OPENCODE_GO_COOKIE_ENV, + OPENCODE_GO_KEYCHAIN_MESSAGE, + OPENCODE_GO_SIGN_IN_MESSAGE, openCodeGoCookieDatabasePaths, parseClaudeSubscriptionQuota, parseCodexSubscriptionQuota, parseCursorSubscriptionQuota, parseGrokSubscriptionQuota, parseGoogleCodeAssistQuota, - resolveOpenCodeGoCookie + resolveOpenCodeGoCookie, + resolveOpenCodeGoCookieResult, + runSubscriptionQuotaProbe } from './provider-subscription-quota.js' function grokBillingFrame(usedPercent: number, resetEpoch: number): Uint8Array { @@ -118,6 +132,10 @@ describe('subscription provider quota parsers', () => { }) describe('resolveOpenCodeGoCookie', () => { + afterEach(() => { + clearOpenCodeGoCookieCache() + }) + it('returns an auth cookie header when a browser has one', async () => { await expect(resolveOpenCodeGoCookie({ cookieDatabasePaths: ['/browsers/chrome/Cookies'], @@ -161,7 +179,7 @@ describe('resolveOpenCodeGoCookie', () => { })).resolves.toBeUndefined() }) - it('resolves platform cookie database paths', () => { + it('resolves platform cookie database paths including Comet and Chrome Beta', () => { const darwin = openCodeGoCookieDatabasePaths({ platform: 'darwin', environment: {}, @@ -169,7 +187,10 @@ describe('resolveOpenCodeGoCookie', () => { }) expect(darwin).toEqual(expect.arrayContaining([ '/Users/kun/Library/Application Support/Google/Chrome/Default/Network/Cookies', - '/Users/kun/Library/Application Support/Arc/User Data/Default/Network/Cookies' + '/Users/kun/Library/Application Support/Google/Chrome Beta/Default/Network/Cookies', + '/Users/kun/Library/Application Support/Arc/User Data/Default/Network/Cookies', + '/Users/kun/Library/Application Support/Comet/Default/Cookies', + '/Users/kun/Library/Application Support/Dia/User Data/Default/Cookies' ])) const windows = openCodeGoCookieDatabasePaths({ platform: 'win32', @@ -178,4 +199,239 @@ describe('resolveOpenCodeGoCookie', () => { }) expect(windows[0]).toBe('C:\\Users\\Kun\\AppData\\Local\\Google\\Chrome\\User Data\\Default\\Network\\Cookies') }) + + it('decrypts Safe Storage cookies when resolving OpenCode Go auth', async () => { + await expect(resolveOpenCodeGoCookie({ + platform: 'darwin', + cookieDatabasePaths: ['/browsers/comet/Cookies'], + readSafeStoragePassword: async () => 'unused-because-readCookies-wins', + readCookies: async () => [ + { name: 'auth', value: 'comet-session' } + ] + })).resolves.toBe('auth=comet-session') + }) + + it('prefers a manual Cookie header and the KUN_OPENCODE_GO_COOKIE env', async () => { + await expect(resolveOpenCodeGoCookieResult({ + environment: { [OPENCODE_GO_COOKIE_ENV]: 'auth=env-token; oc_locale=en' }, + platform: 'linux' + })).resolves.toEqual({ + cookieHeader: 'auth=env-token', + source: 'manual' + }) + + await expect(resolveOpenCodeGoCookieResult({ + manualCookieHeader: 'Cookie: auth=manual-token', + environment: {}, + platform: 'linux', + bypassCache: true + })).resolves.toEqual({ + cookieHeader: 'auth=manual-token', + source: 'manual' + }) + }) + + it('reuses a memory-cached cookie until the cache is cleared', async () => { + await expect(resolveOpenCodeGoCookieResult({ + manualCookieHeader: 'auth=cached-token', + environment: {}, + platform: 'linux' + })).resolves.toMatchObject({ cookieHeader: 'auth=cached-token', source: 'manual' }) + + await expect(resolveOpenCodeGoCookieResult({ + environment: {}, + platform: 'linux' + })).resolves.toEqual({ + cookieHeader: 'auth=cached-token', + source: 'cache' + }) + + clearOpenCodeGoCookieCache() + await expect(resolveOpenCodeGoCookieResult({ + environment: {}, + platform: 'linux', + cookieDatabasePaths: ['/missing/Cookies'], + readCookies: async () => [] + })).resolves.toEqual({ failureReason: 'not_found' }) + expect(getOpenCodeGoCookieFailureReason()).toBe('not_found') + }) + + it('reports decrypt_failed when encrypted browser auth cannot be unlocked', async () => { + const directory = mkdtempSync(join(tmpdir(), 'kun-opencode-cookie-')) + const databasePath = join(directory, 'Cookies') + const key = deriveChromiumSafeStorageKey('secret') + const encrypted = encryptV10Cookie('token', key, 'opencode.ai', 24) + createCookieDatabase(databasePath, [ + { hostKey: 'opencode.ai', name: 'auth', value: '', encryptedHex: encrypted.toString('hex') } + ]) + + await expect(resolveOpenCodeGoCookieResult({ + platform: 'darwin', + environment: {}, + bypassCache: true, + cookieDatabasePaths: [databasePath], + readSafeStoragePassword: async () => undefined + })).resolves.toEqual({ failureReason: 'decrypt_failed' }) + expect(getOpenCodeGoCookieFailureReason()).toBe('decrypt_failed') + }) + + it('surfaces the Keychain message when decrypt fails and local history is empty', async () => { + await expect(runSubscriptionQuotaProbe( + 'opencode-go-local', + { + id: 'opencode-go', + name: 'OpenCode Go', + kind: 'http', + apiKey: '' + }, + { + fetcher: async () => new Response('unused'), + proxyUrl: '' + }, + { + resolveOpenCodeGoCookie: async () => undefined, + resolveOpenCodeGoQuota: async () => undefined + } + )).rejects.toThrow(OPENCODE_GO_SIGN_IN_MESSAGE) + + const directory = mkdtempSync(join(tmpdir(), 'kun-opencode-probe-')) + const databasePath = join(directory, 'Cookies') + const key = deriveChromiumSafeStorageKey('secret') + createCookieDatabase(databasePath, [ + { + hostKey: 'opencode.ai', + name: 'auth', + value: '', + encryptedHex: encryptV10Cookie('token', key, 'opencode.ai', 24).toString('hex') + } + ]) + clearOpenCodeGoCookieCache() + await resolveOpenCodeGoCookieResult({ + platform: 'darwin', + environment: {}, + bypassCache: true, + cookieDatabasePaths: [databasePath], + readSafeStoragePassword: async () => undefined + }) + + await expect(runSubscriptionQuotaProbe( + 'opencode-go-local', + { + id: 'opencode-go', + name: 'OpenCode Go', + kind: 'http', + apiKey: '' + }, + { + fetcher: async () => new Response('unused'), + proxyUrl: '' + }, + { + resolveOpenCodeGoCookie: async () => undefined, + resolveOpenCodeGoQuota: async () => undefined + } + )).rejects.toThrow(OPENCODE_GO_KEYCHAIN_MESSAGE) + }) + + it('clears the cookie cache and retries after invalid_credentials', async () => { + clearOpenCodeGoCookieCache() + await resolveOpenCodeGoCookieResult({ + manualCookieHeader: 'auth=stale-token', + environment: {}, + platform: 'linux' + }) + + let cookieCalls = 0 + let webCalls = 0 + const result = await runSubscriptionQuotaProbe( + 'opencode-go-local', + { + id: 'opencode-go', + name: 'OpenCode Go', + kind: 'http', + apiKey: '' + }, + { + fetcher: async () => new Response('unused'), + proxyUrl: '' + }, + { + resolveOpenCodeGoCookie: async () => { + cookieCalls += 1 + return cookieCalls === 1 ? 'auth=stale-token' : 'auth=fresh-token' + }, + resolveOpenCodeGoQuota: async () => undefined, + fetchOpenCodeGoWebQuota: async (cookieHeader) => { + webCalls += 1 + if (cookieHeader.includes('stale-token')) { + throw new OpenCodeGoWebQuotaError('expired', 'invalid_credentials') + } + return { + metrics: [{ + id: 'five-hour', + label: '5-hour usage', + unit: 'percent', + used: 10, + limit: 100, + remaining: 90, + usedPercent: 10 + }], + summary: 'OpenCode Go subscription · wrk_fresh', + dashboardUrl: 'https://opencode.ai', + workspaceId: 'wrk_fresh' + } + } + } + ) + + expect(result).toMatchObject({ + source: 'OpenCode Go subscription usage', + summary: 'OpenCode Go subscription · wrk_fresh' + }) + expect(cookieCalls).toBe(2) + expect(webCalls).toBe(2) + }) }) + +function createCookieDatabase( + databasePath: string, + rows: Array<{ hostKey: string; name: string; value: string; encryptedHex: string }> +): void { + const binary = process.platform === 'darwin' ? '/usr/bin/sqlite3' : 'sqlite3' + const inserts = rows.map((row) => { + const encryptedSql = row.encryptedHex ? `X'${row.encryptedHex}'` : `X''` + return `INSERT INTO cookies (host_key, name, value, encrypted_value) VALUES ('${row.hostKey}', '${row.name}', '${row.value}', ${encryptedSql});` + }).join('\n') + execFileSync(binary, [databasePath], { + input: ` + CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT); + CREATE TABLE cookies ( + host_key TEXT, + name TEXT, + value TEXT, + encrypted_value BLOB + ); + INSERT INTO meta (key, value) VALUES ('version', '24'); + ${inserts} + `, + encoding: 'utf8' + }) +} + +function encryptV10Cookie( + plaintext: string, + key: Buffer, + hostKey: string, + databaseVersion: number +): Buffer { + const body = databaseVersion >= 24 + ? Buffer.concat([ + createHash('sha256').update(hostKey, 'utf8').digest(), + Buffer.from(plaintext, 'utf8') + ]) + : Buffer.from(plaintext, 'utf8') + const iv = Buffer.alloc(16, 0x20) + const cipher = createCipheriv('aes-128-cbc', key, iv) + const encrypted = Buffer.concat([cipher.update(body), cipher.final()]) + return Buffer.concat([Buffer.from('v10', 'utf8'), encrypted]) +} diff --git a/kun/src/services/provider-subscription-quota.ts b/kun/src/services/provider-subscription-quota.ts index b103ba8ee..d650c0c50 100644 --- a/kun/src/services/provider-subscription-quota.ts +++ b/kun/src/services/provider-subscription-quota.ts @@ -1,7 +1,7 @@ import { execFile } from 'node:child_process' import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' -import { join, win32 } from 'node:path' +import { join } from 'node:path' import { promisify } from 'node:util' import type { ProviderQuotaMetric } from '../contracts/provider-quota.js' import { GeminiCliOAuthSource } from '../adapters/model/gemini-cli-oauth.js' @@ -27,9 +27,15 @@ import { } from './opencode-go-local-quota.js' import { fetchOpenCodeGoWebQuota as fetchOpenCodeGoWebQuotaImpl, + filterOpenCodeGoCookieHeader, OpenCodeGoWebQuotaError, type OpenCodeGoWebQuotaResult } from './opencode-go-web-quota.js' +import { + listChromiumCookieDatabaseCandidates, + readChromiumCookiesForDomainsWithDiagnosis, + type ChromiumCookieDatabaseCandidate +} from './chromium-browser-cookies.js' const execFileAsync = promisify(execFile) const QUOTA_TIMEOUT_MS = 12_000 @@ -222,33 +228,7 @@ export async function runSubscriptionQuotaProbe( return probeGoogleCodeAssistQuota(credential, context, 'antigravity') } if (kind === 'opencode-go-local') { - const cookieHeader = await runtime.resolveOpenCodeGoCookie() - if (cookieHeader) { - try { - const web = await runtime.fetchOpenCodeGoWebQuota(cookieHeader, context) - if (web.metrics.length > 0) { - return { - metrics: web.metrics, - ...(web.summary ? { summary: web.summary } : {}), - source: 'OpenCode Go subscription usage' - } - } - } catch (error) { - // Web quota is best-effort: any auth/network/parse failure falls back - // to the local usage database estimate. - if (!(error instanceof OpenCodeGoWebQuotaError)) throw error - } - } - const quota = await runtime.resolveOpenCodeGoQuota() - if (quota) { - return { - ...quota, - source: 'OpenCode Go local usage estimate' - } - } - throw new ProviderQuotaMissingCredentialError( - 'Sign in to opencode.ai in your browser, or use OpenCode Go locally first so its usage history exists.' - ) + return probeOpenCodeGoLocalQuota(runtime, context) } const accessToken = await runtime.resolveGeminiCliToken(context) if (!accessToken) { @@ -259,6 +239,59 @@ export async function runSubscriptionQuotaProbe( return probeGoogleCodeAssistQuota({ accessToken }, context, 'gemini-cli') } +async function probeOpenCodeGoLocalQuota( + runtime: SubscriptionQuotaRuntime, + context: ProbeContext +): Promise<{ metrics: ProviderQuotaMetric[]; summary?: string; source?: string }> { + const tryWeb = async (cookieHeader: string) => { + const web = await runtime.fetchOpenCodeGoWebQuota(cookieHeader, context) + if (web.metrics.length > 0) { + return { + metrics: web.metrics, + ...(web.summary ? { summary: web.summary } : {}), + source: 'OpenCode Go subscription usage' + } as const + } + return undefined + } + + let cookieHeader = await runtime.resolveOpenCodeGoCookie() + if (cookieHeader) { + try { + const web = await tryWeb(cookieHeader) + if (web) return web + } catch (error) { + if (!(error instanceof OpenCodeGoWebQuotaError)) throw error + if (error.code === 'invalid_credentials') { + clearOpenCodeGoCookieCache() + cookieHeader = await runtime.resolveOpenCodeGoCookie() + if (cookieHeader) { + try { + const web = await tryWeb(cookieHeader) + if (web) return web + } catch (retryError) { + if (!(retryError instanceof OpenCodeGoWebQuotaError)) throw retryError + } + } + } + } + } + + const quota = await runtime.resolveOpenCodeGoQuota() + if (quota) { + return { + ...quota, + source: 'OpenCode Go local usage estimate' + } + } + + throw new ProviderQuotaMissingCredentialError( + getOpenCodeGoCookieFailureReason() === 'decrypt_failed' + ? OPENCODE_GO_KEYCHAIN_MESSAGE + : OPENCODE_GO_SIGN_IN_MESSAGE + ) +} + export function parseClaudeSubscriptionQuota(payload: unknown): ProviderQuotaMetric[] { const root = requireRecord(payload, 'Claude returned an invalid usage response.') const metrics: ProviderQuotaMetric[] = [] @@ -1410,107 +1443,261 @@ export type OpenCodeGoCookieResolverOptions = { platform?: NodeJS.Platform environment?: NodeJS.ProcessEnv homeDirectory?: string + /** Prefer this Cookie header over env/cache/browser import. */ + manualCookieHeader?: string + /** When true, skip the in-memory / Keychain cookie cache. */ + bypassCache?: boolean cookieDatabasePaths?: string[] readCookies?: (databasePath: string) => Promise> + readSafeStoragePassword?: ( + label: { service: string; account: string } + ) => Promise +} + +export type OpenCodeGoCookieFailureReason = 'not_found' | 'decrypt_failed' + +export type OpenCodeGoCookieResolveResult = { + cookieHeader?: string + source?: 'manual' | 'cache' | 'browser' + failureReason?: OpenCodeGoCookieFailureReason } +export const OPENCODE_GO_SIGN_IN_MESSAGE = + 'Sign in to opencode.ai in your browser, or use OpenCode Go locally first so its usage history exists.' + +export const OPENCODE_GO_KEYCHAIN_MESSAGE = + 'Found an opencode.ai browser session, but could not unlock the browser Safe Storage keychain. Allow Keychain access for Kun (Chrome/Comet Safe Storage), or set KUN_OPENCODE_GO_COOKIE to a Cookie header.' + +export const OPENCODE_GO_COOKIE_ENV = 'KUN_OPENCODE_GO_COOKIE' + const OPENCODE_COOKIE_NAMES = new Set(['auth', '__host-auth']) +const OPENCODE_GO_COOKIE_DOMAINS = ['opencode.ai', 'app.opencode.ai'] +const OPENCODE_GO_CACHE_SERVICE = 'kun-opencode-go' +const OPENCODE_GO_CACHE_ACCOUNT = 'session-cookie' + +let openCodeGoCookieMemoryCache: string | undefined +let openCodeGoCookieFailureReason: OpenCodeGoCookieFailureReason | undefined + +/** Last browser-import failure for OpenCode Go quota probe messaging. */ +export function getOpenCodeGoCookieFailureReason(): OpenCodeGoCookieFailureReason | undefined { + return openCodeGoCookieFailureReason +} + +/** Clears in-memory and Keychain-cached OpenCode Go session cookies. */ +export function clearOpenCodeGoCookieCache(): void { + openCodeGoCookieMemoryCache = undefined + openCodeGoCookieFailureReason = undefined + void clearPersistedOpenCodeGoCookieCache() +} /** - * Resolves an OpenCode session cookie header (auth / __Host-auth) from - * installed Chromium-family browsers. Any read failure, missing cookie, or - * encrypted cookie value returns undefined so callers fall back to the local - * usage database instead of surfacing an error. + * Resolves an OpenCode session cookie header (auth / __Host-auth) from manual + * config/env, a short-lived cache, or installed Chromium-family browsers + * (including Comet/Dia), decrypting macOS Safe Storage values when needed. + * Any read failure, missing cookie, or undecryptable cookie returns undefined + * so callers fall back to the local usage database instead of surfacing an + * error — use {@link getOpenCodeGoCookieFailureReason} for the specific cause. */ export async function resolveOpenCodeGoCookie( options: OpenCodeGoCookieResolverOptions = {} ): Promise { - const databasePaths = options.cookieDatabasePaths ?? - openCodeGoCookieDatabasePaths(options) - const readCookies = options.readCookies ?? readChromiumCookies - for (const databasePath of databasePaths) { - try { - const cookies = await readCookies(databasePath) - const pairs = cookies - .filter((cookie) => OPENCODE_COOKIE_NAMES.has(cookie.name.toLowerCase())) - .filter((cookie) => cookie.value.trim().length > 0) - // Chrome 137+ encrypts cookie values with a v10 prefix; those cannot be - // decrypted with the sqlite3 CLI and are treated as absent. - .filter((cookie) => !cookie.value.startsWith('v10')) - .map((cookie) => `${cookie.name}=${cookie.value}`) - if (pairs.length > 0) return pairs.join('; ') - } catch { - // Browser cookie databases may be locked or encrypted; try the next candidate. - } - } - return undefined + const result = await resolveOpenCodeGoCookieResult(options) + return result.cookieHeader } -export function openCodeGoCookieDatabasePaths( - options: Omit = {} -): string[] { - const platform = options.platform ?? process.platform +export async function resolveOpenCodeGoCookieResult( + options: OpenCodeGoCookieResolverOptions = {} +): Promise { const environment = options.environment ?? process.env - const userHome = options.homeDirectory ?? homedir() - const paths: string[] = [] - if (platform === 'darwin') { - const root = join(userHome, 'Library', 'Application Support') - paths.push( - join(root, 'Google', 'Chrome'), - join(root, 'Microsoft Edge'), - join(root, 'BraveSoftware', 'Brave-Browser'), - join(root, 'Arc', 'User Data') - ) - } else if (platform === 'linux') { - const root = join(userHome, '.config') - paths.push( - join(root, 'google-chrome'), - join(root, 'microsoft-edge'), - join(root, 'brave'), - join(root, 'arc') - ) - } else if (platform === 'win32') { - const localAppData = environment.LOCALAPPDATA?.trim() - const root = localAppData || join(userHome, 'AppData', 'Local') - const joinPath = win32.join - paths.push( - joinPath(root, 'Google', 'Chrome', 'User Data'), - joinPath(root, 'Microsoft', 'Edge', 'User Data'), - joinPath(root, 'BraveSoftware', 'Brave-Browser', 'User Data') + const injectedReader = Boolean(options.readCookies || options.cookieDatabasePaths) + const allowCache = !options.bypassCache && !injectedReader + + if (!injectedReader) { + const manual = filterOpenCodeGoCookieHeader( + options.manualCookieHeader ?? + environment[OPENCODE_GO_COOKIE_ENV] ?? + undefined ) + if (manual) { + openCodeGoCookieFailureReason = undefined + openCodeGoCookieMemoryCache = manual + void persistOpenCodeGoCookieCache(manual, options.platform) + return { cookieHeader: manual, source: 'manual' } + } + + if (allowCache) { + const cached = openCodeGoCookieMemoryCache ?? + await loadPersistedOpenCodeGoCookieCache(options.platform) + const filteredCached = filterOpenCodeGoCookieHeader(cached) + if (filteredCached) { + openCodeGoCookieMemoryCache = filteredCached + openCodeGoCookieFailureReason = undefined + return { cookieHeader: filteredCached, source: 'cache' } + } + } + } else if (options.manualCookieHeader) { + const manual = filterOpenCodeGoCookieHeader(options.manualCookieHeader) + if (manual) { + openCodeGoCookieFailureReason = undefined + return { cookieHeader: manual, source: 'manual' } + } } - const joinPath = platform === 'win32' ? win32.join : join - return paths.flatMap((browserRoot) => [ - joinPath(browserRoot, 'Default', 'Network', 'Cookies'), - joinPath(browserRoot, 'Default', 'Cookies') - ]) + + // Tests and callers can still inject plaintext cookie readers per DB path. + if (injectedReader) { + const databasePaths = options.cookieDatabasePaths ?? + openCodeGoCookieDatabasePaths(options) + const readCookies = options.readCookies + if (!readCookies) { + return resolveOpenCodeGoCookieFromChromiumSources({ + ...options, + candidates: databasePaths.map((databasePath) => ({ + browser: { + id: 'custom', + displayName: 'Custom', + profileRootSegments: [], + // Allow Safe Storage overrides when callers inject DB paths only. + safeStorageLabels: [ + { service: 'Chrome Safe Storage', account: 'Chrome' }, + { service: 'Comet Safe Storage', account: 'Comet' } + ] + }, + databasePath + })) + }) + } + for (const databasePath of databasePaths) { + try { + const cookies = await readCookies(databasePath) + const pairs = cookies + .filter((cookie) => OPENCODE_COOKIE_NAMES.has(cookie.name.toLowerCase())) + .filter((cookie) => cookie.value.trim().length > 0) + .filter((cookie) => !cookie.value.startsWith('v10')) + .map((cookie) => `${cookie.name}=${cookie.value}`) + if (pairs.length > 0) { + const cookieHeader = pairs.join('; ') + openCodeGoCookieFailureReason = undefined + return { cookieHeader, source: 'browser' } + } + } catch { + // Browser cookie databases may be locked; try the next candidate. + } + } + openCodeGoCookieFailureReason = 'not_found' + return { failureReason: 'not_found' } + } + + return resolveOpenCodeGoCookieFromChromiumSources(options) } -async function readChromiumCookies( - databasePath: string -): Promise> { - const binary = process.platform === 'darwin' ? '/usr/bin/sqlite3' : 'sqlite3' - const { stdout } = await execFileAsync(binary, [ - databasePath, - "SELECT name, value FROM cookies WHERE host_key LIKE '%opencode.ai';" - ], { - encoding: 'utf8', - timeout: 2_000, - maxBuffer: 512 * 1024 +async function resolveOpenCodeGoCookieFromChromiumSources( + options: OpenCodeGoCookieResolverOptions & { + candidates?: ChromiumCookieDatabaseCandidate[] + } +): Promise { + const { cookies, diagnosis } = await readChromiumCookiesForDomainsWithDiagnosis({ + platform: options.platform, + environment: options.environment, + homeDirectory: options.homeDirectory, + candidates: options.candidates, + domainSuffixes: OPENCODE_GO_COOKIE_DOMAINS, + cookieNames: OPENCODE_COOKIE_NAMES, + ...(options.readSafeStoragePassword + ? { readSafeStoragePassword: options.readSafeStoragePassword } + : {}) }) - return stdout - .split('\n') - .map((line) => { - const separator = line.indexOf('|') - if (separator <= 0) return undefined - return { - name: line.slice(0, separator).trim(), - value: line.slice(separator + 1) - } + const pairs = cookies + .filter((cookie) => OPENCODE_COOKIE_NAMES.has(cookie.name.toLowerCase())) + .filter((cookie) => cookie.value.trim().length > 0) + .map((cookie) => `${cookie.name}=${cookie.value}`) + if (pairs.length > 0) { + const cookieHeader = pairs.join('; ') + openCodeGoCookieFailureReason = undefined + openCodeGoCookieMemoryCache = cookieHeader + void persistOpenCodeGoCookieCache(cookieHeader, options.platform) + return { cookieHeader, source: 'browser' } + } + const failureReason: OpenCodeGoCookieFailureReason = + diagnosis.kind === 'decrypt_failed' ? 'decrypt_failed' : 'not_found' + openCodeGoCookieFailureReason = failureReason + return { failureReason } +} + +async function loadPersistedOpenCodeGoCookieCache( + platform: NodeJS.Platform | undefined +): Promise { + if ((platform ?? process.platform) !== 'darwin') return undefined + try { + const { stdout } = await execFileAsync('security', [ + 'find-generic-password', + '-w', + '-s', + OPENCODE_GO_CACHE_SERVICE, + '-a', + OPENCODE_GO_CACHE_ACCOUNT + ], { + encoding: 'utf8', + timeout: 2_000, + maxBuffer: 64 * 1024 }) - .filter((row): row is { name: string; value: string } => - row !== undefined && - row.name.length > 0) + return filterOpenCodeGoCookieHeader(stdout.trim()) || undefined + } catch { + return undefined + } +} + +async function persistOpenCodeGoCookieCache( + cookieHeader: string, + platform: NodeJS.Platform | undefined +): Promise { + if ((platform ?? process.platform) !== 'darwin') return + try { + await execFileAsync('security', [ + 'add-generic-password', + '-U', + '-s', + OPENCODE_GO_CACHE_SERVICE, + '-a', + OPENCODE_GO_CACHE_ACCOUNT, + '-w', + cookieHeader + ], { + encoding: 'utf8', + timeout: 2_000, + maxBuffer: 64 * 1024 + }) + } catch { + // Cache persistence is best-effort. + } +} + +async function clearPersistedOpenCodeGoCookieCache(): Promise { + if (process.platform !== 'darwin') return + try { + await execFileAsync('security', [ + 'delete-generic-password', + '-s', + OPENCODE_GO_CACHE_SERVICE, + '-a', + OPENCODE_GO_CACHE_ACCOUNT + ], { + encoding: 'utf8', + timeout: 2_000, + maxBuffer: 64 * 1024 + }) + } catch { + // Missing cache entries are fine. + } +} + +export function openCodeGoCookieDatabasePaths( + options: Omit = {} +): string[] { + return listChromiumCookieDatabaseCandidates({ + platform: options.platform, + environment: options.environment, + homeDirectory: options.homeDirectory + }).map((candidate) => candidate.databasePath) } async function readJsonFile(path: string): Promise { diff --git a/kun/src/services/thread-service.ts b/kun/src/services/thread-service.ts index 77503bb7d..41813f494 100644 --- a/kun/src/services/thread-service.ts +++ b/kun/src/services/thread-service.ts @@ -174,6 +174,17 @@ export class ThreadService { return this.threadStore.get(threadId) } + /** + * Read the thread/turn metadata without hydrating the item history when the + * backing store supports it. File/hybrid stores use this on detail and + * status routes so the session items are loaded exactly once. + */ + async getMetadata(threadId: string): Promise { + return this.threadStore.getMetadata + ? this.threadStore.getMetadata(threadId) + : this.threadStore.get(threadId) + } + async create( request: CreateThreadRequest, options: { diff --git a/kun/src/services/tool-cancellation-service.test.ts b/kun/src/services/tool-cancellation-service.test.ts new file mode 100644 index 000000000..efbf7a731 --- /dev/null +++ b/kun/src/services/tool-cancellation-service.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' +import { makeToolCallItem } from '../domain/item.js' +import type { ToolCallTurnItem } from '../contracts/items.js' +import type { Turn } from '../contracts/turns.js' +import { ToolCancellationRegistry } from '../loop/tool-cancellation-registry.js' +import { ToolCancellationService } from './tool-cancellation-service.js' + +describe('ToolCancellationService', () => { + it('records the request and keeps retries idempotent after execution cleanup', async () => { + const call: ToolCallTurnItem = makeToolCallItem({ + id: 'item_call', + threadId: 'thread', + turnId: 'turn', + callId: 'call', + toolName: 'read', + arguments: {}, + status: 'running' + }) as ToolCallTurnItem + const turn = { + id: 'turn', + threadId: 'thread', + status: 'running', + items: [call] + } as unknown as Turn + const getTurn = vi.fn(async () => turn) + const updateItem = vi.fn(async (_threadId: string, _itemId: string, patch: Record) => { + Object.assign(call, patch) + return call + }) + const registry = new ToolCancellationRegistry() + const registration = registry.register( + { threadId: 'thread', turnId: 'turn', callId: 'call' }, + new AbortController().signal + ) + const service = new ToolCancellationService( + { getTurn, updateItem } as never, + registry, + () => '2026-08-07T00:00:00.000Z' + ) + + await expect(service.cancel({ threadId: 'thread', turnId: 'turn', callId: 'call' })) + .resolves.toMatchObject({ status: 'cancellation_requested' }) + expect(call.cancelRequestedAt).toBe('2026-08-07T00:00:00.000Z') + expect(updateItem).toHaveBeenCalledWith('thread', 'item_call', { + cancelRequestedAt: '2026-08-07T00:00:00.000Z' + }) + + await expect(service.cancel({ threadId: 'thread', turnId: 'turn', callId: 'call' })) + .resolves.toMatchObject({ status: 'already_requested' }) + registration.dispose() + await expect(service.cancel({ threadId: 'thread', turnId: 'turn', callId: 'call' })) + .resolves.toMatchObject({ status: 'already_requested' }) + }) +}) diff --git a/kun/src/services/tool-cancellation-service.ts b/kun/src/services/tool-cancellation-service.ts new file mode 100644 index 000000000..2f14a1e86 --- /dev/null +++ b/kun/src/services/tool-cancellation-service.ts @@ -0,0 +1,63 @@ +import type { ToolCallTurnItem } from '../contracts/items.js' +import type { TurnService } from './turn-service.js' +import { + ToolCancellationRegistry, + type ToolCancellationRequestStatus +} from '../loop/tool-cancellation-registry.js' + +export type ToolCancellationServiceResult = { + threadId: string + turnId: string + callId: string + status: Extract +} + +/** Coordinates durable request state with the process-local tool signal. */ +export class ToolCancellationService { + constructor( + private readonly turns: Pick, + private readonly registry: ToolCancellationRegistry, + private readonly nowIso: () => string + ) {} + + async cancel(input: { + threadId: string + turnId: string + callId: string + }): Promise { + const turn = await this.turns.getTurn(input.threadId, input.turnId) + if (!turn) throw new Error(`turn not found: ${input.turnId}`) + if (turn.status !== 'queued' && turn.status !== 'running') { + throw new Error(`turn is no longer active: ${input.turnId}`) + } + const call = turn.items.find( + (item): item is ToolCallTurnItem => item.kind === 'tool_call' && item.callId === input.callId + ) + if (!call) throw new Error(`tool call not found: ${input.callId}`) + + const status = this.registry.request(input, this.nowIso()) + // The registry is process-local and removes a handle in `finally` as soon + // as the tool settles. Keep a durable marker as the idempotency record so + // a retry that races just after completion still reports success rather + // than turning an already accepted cancellation into a 409. + if (status === 'not_found' && call.cancelRequestedAt) { + return { ...input, status: 'already_requested' } + } + if (status === 'not_found') { + throw new Error(`tool call is not currently executing: ${input.callId}`) + } + if (status === 'turn_aborted') { + throw new Error(`turn is already being interrupted: ${input.turnId}`) + } + + if (!call.cancelRequestedAt) { + await this.turns.updateItem(input.threadId, call.id, { + cancelRequestedAt: this.nowIso() + }) + } + return { + ...input, + status + } + } +} diff --git a/kun/src/services/turn-service.ts b/kun/src/services/turn-service.ts index 3b22a897d..3b1d9c4d9 100644 --- a/kun/src/services/turn-service.ts +++ b/kun/src/services/turn-service.ts @@ -1040,6 +1040,9 @@ export class TurnService { this.clearRuntimeTurnState(input.threadId, input.turnId) await this.finalizePersistedOpenItems(input.threadId, input.turnId, input.status) + // The turn's usage metrics are now stable; release per-turn aggregation + // so long-lived threads do not accumulate one entry per historical turn. + this.deps.usage?.endTurn(input.threadId, input.turnId) const errorItem = input.error ? makeErrorItem({ id: `item_${input.turnId}_error`, diff --git a/kun/src/services/usage-service.test.ts b/kun/src/services/usage-service.test.ts index 27c2295c9..2e4e2c6be 100644 --- a/kun/src/services/usage-service.test.ts +++ b/kun/src/services/usage-service.test.ts @@ -127,3 +127,58 @@ describe('usage cache diagnostics', () => { }) }) }) + +describe('usage per-turn timing aggregation', () => { + const timed = (overrides: Record) => ({ + promptTokens: 100, + completionTokens: 10, + totalTokens: 110, + cacheHitRate: null, + turns: 1, + ...overrides + }) + + it('attaches turn averages to the cumulative snapshot per turnId', () => { + const usage = new UsageService() + // Turn A: two model calls within one user turn. + usage.record('thread-a', timed({ completionTokens: 40, requestTtftMs: 800, requestGenerationMs: 2_000 }), undefined, 'turn-a') + const turnASnapshot = usage.record('thread-a', timed({ completionTokens: 120, requestTtftMs: 1_200, requestGenerationMs: 2_000 }), undefined, 'turn-a') + // Turn B: separate averages, must not bleed into turn A. + const afterTurnB = usage.record('thread-a', timed({ completionTokens: 50, requestTtftMs: 500, requestGenerationMs: 1_000 }), undefined, 'turn-b') + + expect(turnASnapshot.turnAvgTtftMs).toBe(1_000) + expect(turnASnapshot.turnAvgTokensPerSecond).toBe(40) + // Session averages aggregate across all calls in the thread. + expect(afterTurnB.avgTtftMs).toBe((800 + 1_200 + 500) / 3) + expect(afterTurnB.avgTokensPerSecond).toBe(210 / 5_000 * 1_000) + // Turn B has its own fresh averages. + expect(afterTurnB.turnAvgTtftMs).toBe(500) + }) + + it('reports null turn averages without timing data', () => { + const usage = new UsageService() + const snapshot = usage.record('thread-a', timed({}), undefined, 'turn-a') + + expect(snapshot.turnAvgTtftMs).toBeNull() + expect(snapshot.turnAvgTokensPerSecond).toBeNull() + }) + + it('does not fold timing into a turn when turnId is omitted', () => { + const usage = new UsageService() + const snapshot = usage.record('thread-a', timed({ requestTtftMs: 900, requestGenerationMs: 1_000 })) + + expect(snapshot.turnAvgTtftMs).toBeUndefined() + // Session aggregation still applies. + expect(snapshot.avgTtftMs).toBe(900) + }) + + it('endTurn releases per-turn aggregation for finished turns', () => { + const usage = new UsageService() + usage.record('thread-a', timed({ requestTtftMs: 800, requestGenerationMs: 1_000 }), undefined, 'turn-a') + usage.endTurn('thread-a', 'turn-a') + + // A new call in the same turnId starts a fresh aggregation window. + const next = usage.record('thread-a', timed({ requestTtftMs: 200, requestGenerationMs: 1_000 }), undefined, 'turn-a') + expect(next.turnAvgTtftMs).toBe(200) + }) +}) diff --git a/kun/src/services/usage-service.ts b/kun/src/services/usage-service.ts index 22e5b8f7d..c451e25f9 100644 --- a/kun/src/services/usage-service.ts +++ b/kun/src/services/usage-service.ts @@ -25,6 +25,8 @@ export class UsageService { private readonly counter = new UsageCounter() private readonly cache = new CacheTelemetry() private readonly cacheSignatures = new Map() + /** Raw per-request timing sums keyed by `threadId::turnId`. */ + private readonly turnTiming = new Map() /** * Rolling cacheable-hit-rate history keyed by thread + provider/model/endpoint * so a model or provider switch starts a FRESH baseline instead of polluting @@ -41,11 +43,16 @@ export class UsageService { record( threadId: string, usage: UsageSnapshot, - signature?: CacheRequestSignature + signature?: CacheRequestSignature, + turnId?: string ): UsageSnapshot { const enriched = signature ? this.withCacheDiagnostics(threadId, usage, signature) : usage this.cache.ingest(threadId, enriched) - return this.counter.record(threadId, enriched) + const cumulative = this.counter.record(threadId, enriched) + if (turnId) { + return attachTurnAverages(cumulative, this.foldTurnTiming(threadId, turnId, enriched)) + } + return cumulative } recordTokenEconomySavings( @@ -64,6 +71,7 @@ export class UsageService { this.cache.ingest(threadId, seeded) this.cacheSignatures.delete(threadId) this.clearCacheHistory(threadId) + this.clearTurnTiming(threadId) return seeded } @@ -82,14 +90,64 @@ export class UsageService { reset(threadId?: string): void { this.counter.reset(threadId) this.cache.reset(threadId) - if (threadId === undefined) this.cacheSignatures.clear() - else this.cacheSignatures.delete(threadId) + if (threadId === undefined) { + this.cacheSignatures.clear() + } else { + this.cacheSignatures.delete(threadId) + } if (threadId === undefined) { this.cacheHitHistory.clear() this.cacheRegressionCooldown.clear() } else { this.clearCacheHistory(threadId) } + this.clearTurnTiming(threadId) + } + + /** + * Drop per-turn timing for a finished turn so long-lived threads do not + * accumulate one entry per historical turn. Call when the turn settles. + */ + endTurn(threadId: string, turnId: string): void { + this.turnTiming.delete(this.turnKey(threadId, turnId)) + } + + private turnKey(threadId: string, turnId: string): string { + return `${threadId}::${turnId}` + } + + private clearTurnTiming(threadId?: string): void { + if (threadId === undefined) { + this.turnTiming.clear() + return + } + const prefix = `${threadId}::` + for (const key of this.turnTiming.keys()) { + if (key.startsWith(prefix)) this.turnTiming.delete(key) + } + } + + private foldTurnTiming(threadId: string, turnId: string, snapshot: UsageSnapshot): TurnTiming { + const key = this.turnKey(threadId, turnId) + const agg = this.turnTiming.get(key) ?? emptyTurnTiming() + const ttft = snapshot.requestTtftMs + if (typeof ttft === 'number' && Number.isFinite(ttft) && ttft >= 0) { + agg.ttftSumMs += ttft + agg.ttftCalls += 1 + } + const generation = snapshot.requestGenerationMs + if ( + typeof generation === 'number' && + Number.isFinite(generation) && + generation >= 0 && + snapshot.completionTokens > 0 + ) { + agg.generationSumMs += generation + agg.completionTokensSum += snapshot.completionTokens + agg.tpsCalls += 1 + } + this.turnTiming.set(key, agg) + return agg } /** Drop all signature-keyed cache history + cooldown rows for one thread. */ @@ -159,6 +217,40 @@ export class UsageService { } } +type TurnTiming = { + ttftSumMs: number + generationSumMs: number + completionTokensSum: number + ttftCalls: number + tpsCalls: number +} + +function emptyTurnTiming(): TurnTiming { + return { + ttftSumMs: 0, + generationSumMs: 0, + completionTokensSum: 0, + ttftCalls: 0, + tpsCalls: 0 + } +} + +/** + * Attach this turn's averages to the cumulative snapshot. TTFT is a simple + * mean; tokens-per-second is weighted by total generated tokens over total + * generation time. + */ +function attachTurnAverages(snapshot: UsageSnapshot, timing: TurnTiming): UsageSnapshot { + return { + ...snapshot, + turnAvgTtftMs: timing.ttftCalls > 0 ? timing.ttftSumMs / timing.ttftCalls : null, + turnAvgTokensPerSecond: + timing.generationSumMs > 0 + ? (timing.completionTokensSum / timing.generationSumMs) * 1_000 + : null + } +} + export const MAX_DAILY_USAGE_DAYS = 370 /** Rolling window of recent cacheable-hit-rate samples kept per thread. */ diff --git a/kun/src/telemetry/usage-counter.test.ts b/kun/src/telemetry/usage-counter.test.ts index 83c8546af..4fd34d5dc 100644 --- a/kun/src/telemetry/usage-counter.test.ts +++ b/kun/src/telemetry/usage-counter.test.ts @@ -131,3 +131,84 @@ describe('UsageCounter.total cross-thread aggregate', () => { }) }) }) + +describe('UsageCounter timing aggregation', () => { + it('derives thread-cumulative TTFT and tokens-per-second averages', () => { + const counter = new UsageCounter() + // TTFT simple mean: (800 + 1200) / 2 = 1000ms. + // TPS weighted: (50 + 150) / (2s + 2s) * 1000 = 50 tok/s. + counter.record('thread-a', snapshot({ + completionTokens: 50, + requestTtftMs: 800, + requestGenerationMs: 2_000 + })) + counter.record('thread-a', snapshot({ + completionTokens: 150, + requestTtftMs: 1_200, + requestGenerationMs: 2_000 + })) + + const usage = counter.forThread('thread-a') + expect(usage.avgTtftMs).toBe(1_000) + expect(usage.avgTokensPerSecond).toBe(50) + }) + + it('treats missing timing fields as null instead of zero', () => { + const counter = new UsageCounter() + counter.record('thread-a', snapshot({ completionTokens: 10 })) + + const usage = counter.forThread('thread-a') + expect(usage.avgTtftMs).toBeNull() + expect(usage.avgTokensPerSecond).toBeNull() + }) + + it('ignores invalid timing and mixes timed and untimed requests', () => { + const counter = new UsageCounter() + counter.record('thread-a', snapshot({ completionTokens: 100 })) + counter.record('thread-a', snapshot({ + completionTokens: 100, + requestTtftMs: 400, + requestGenerationMs: 1_000 + })) + + const usage = counter.forThread('thread-a') + // Only the timed request contributes to the TTFT average. + expect(usage.avgTtftMs).toBe(400) + expect(usage.avgTokensPerSecond).toBe(100) + }) + + it('recomputes timing averages across threads in total()', () => { + const counter = new UsageCounter() + counter.record('thread-a', snapshot({ + completionTokens: 100, + requestTtftMs: 1_000, + requestGenerationMs: 2_000 + })) + counter.record('thread-b', snapshot({ + completionTokens: 300, + requestTtftMs: 3_000, + requestGenerationMs: 2_000 + })) + + const total = counter.total() + expect(total.avgTtftMs).toBe(2_000) + expect(total.avgTokensPerSecond).toBe(100) + }) + + it('resets timing together with the thread counter', () => { + const counter = new UsageCounter() + counter.record('thread-a', snapshot({ + completionTokens: 100, + requestTtftMs: 1_000, + requestGenerationMs: 2_000 + })) + counter.reset('thread-a') + expect(counter.forThread('thread-a').avgTtftMs).toBeNull() + expect(counter.forThread('thread-a').avgTokensPerSecond).toBeNull() + + // Seed restores snapshot values but starts timing history fresh. + counter.seed('thread-a', snapshot({ promptTokens: 5, completionTokens: 5 })) + expect(counter.forThread('thread-a').promptTokens).toBe(5) + expect(counter.forThread('thread-a').avgTtftMs).toBeNull() + }) +}) diff --git a/kun/src/telemetry/usage-counter.ts b/kun/src/telemetry/usage-counter.ts index 5a53b533e..0123e7347 100644 --- a/kun/src/telemetry/usage-counter.ts +++ b/kun/src/telemetry/usage-counter.ts @@ -8,18 +8,24 @@ import { emptyUsageSnapshot } from '../contracts/usage.js' */ export class UsageCounter { private perThread = new Map() + /** Raw per-request timing sums keyed by thread, used to derive averages. */ + private readonly timing = new Map() reset(threadId?: string): void { if (threadId === undefined) { this.perThread.clear() + this.timing.clear() return } this.perThread.delete(threadId) + this.timing.delete(threadId) } seed(threadId: string, snapshot: UsageSnapshot): UsageSnapshot { - const next = normalizeUsageSnapshot(snapshot) + const next = attachTimingAverages(normalizeUsageSnapshot(snapshot), emptyTimingAgg()) this.perThread.set(threadId, next) + // Restored threads have no in-process timing history. + this.timing.delete(threadId) return next } @@ -99,8 +105,10 @@ export class UsageCounter { tokenEconomySavingsCny, hasError: snapshot.hasError } - this.perThread.set(threadId, next) - return next + const threadTiming = this.timing.get(threadId) ?? emptyTimingAgg() + this.timing.set(threadId, foldTiming(threadTiming, snapshot)) + this.perThread.set(threadId, attachTimingAverages(next, this.timing.get(threadId)!)) + return this.perThread.get(threadId)! } recordTokenEconomySavings( @@ -132,7 +140,8 @@ export class UsageCounter { const totals = [...this.perThread.values()].reduce((acc, snapshot) => { return mergeUsage(acc, snapshot) }, emptyUsageSnapshot()) - return totals + const timing = aggregateTiming([...this.timing.values()]) + return attachTimingAverages(totals, timing) } forThread(threadId: string): UsageSnapshot { @@ -140,6 +149,73 @@ export class UsageCounter { } } +type TimingAgg = { + ttftSumMs: number + generationSumMs: number + completionTokensSum: number + ttftCalls: number + tpsCalls: number +} + +function emptyTimingAgg(): TimingAgg { + return { + ttftSumMs: 0, + generationSumMs: 0, + completionTokensSum: 0, + ttftCalls: 0, + tpsCalls: 0 + } +} + +/** Fold one request's timing fields into the thread aggregate. */ +function foldTiming(agg: TimingAgg, snapshot: UsageSnapshot): TimingAgg { + const ttft = snapshot.requestTtftMs + if (typeof ttft === 'number' && Number.isFinite(ttft) && ttft >= 0) { + agg.ttftSumMs += ttft + agg.ttftCalls += 1 + } + const generation = snapshot.requestGenerationMs + if ( + typeof generation === 'number' && + Number.isFinite(generation) && + generation >= 0 && + snapshot.completionTokens > 0 + ) { + agg.generationSumMs += generation + agg.completionTokensSum += snapshot.completionTokens + agg.tpsCalls += 1 + } + return agg +} + +function aggregateTiming(items: readonly TimingAgg[]): TimingAgg { + const agg = emptyTimingAgg() + for (const item of items) { + agg.ttftSumMs += item.ttftSumMs + agg.generationSumMs += item.generationSumMs + agg.completionTokensSum += item.completionTokensSum + agg.ttftCalls += item.ttftCalls + agg.tpsCalls += item.tpsCalls + } + return agg +} + +/** + * Attach thread-cumulative averages to a snapshot. TTFT is a simple mean + * over timed requests; tokens-per-second is a weighted mean computed from + * total generated tokens divided by total generation time. + */ +function attachTimingAverages(snapshot: UsageSnapshot, timing: TimingAgg): UsageSnapshot { + return { + ...snapshot, + avgTtftMs: timing.ttftCalls > 0 ? timing.ttftSumMs / timing.ttftCalls : null, + avgTokensPerSecond: + timing.generationSumMs > 0 + ? (timing.completionTokensSum / timing.generationSumMs) * 1_000 + : null + } +} + function normalizeUsageSnapshot(snapshot: UsageSnapshot): UsageSnapshot { const promptTokens = Math.max(0, Math.floor(snapshot.promptTokens)) const completionTokens = Math.max(0, Math.floor(snapshot.completionTokens)) diff --git a/kun/tests/loop.test.ts b/kun/tests/loop.test.ts index 17585bf87..45eb284fd 100644 --- a/kun/tests/loop.test.ts +++ b/kun/tests/loop.test.ts @@ -115,21 +115,6 @@ describe('AgentLoop', () => { )).toEqual([expect.objectContaining({ kind: 'turn_aborted' })]) }) - it('bounds cached prompt-pressure hydration markers', () => { - const telemetry = new LoopTelemetry({} as unknown as SessionStore) as unknown as { - rememberHydratedPressureThread(threadId: string): void - hydratedPressureThreads: Set - } - - for (let index = 0; index <= 512; index += 1) { - telemetry.rememberHydratedPressureThread(`thread_${index}`) - } - - expect(telemetry.hydratedPressureThreads).toHaveLength(512) - expect(telemetry.hydratedPressureThreads.has('thread_0')).toBe(false) - expect(telemetry.hydratedPressureThreads.has('thread_512')).toBe(true) - }) - it('injects the current shell runtime under the full-access sandbox', async () => { let observedRequest: ModelRequest | null = null const h = makeHarness({ @@ -445,7 +430,7 @@ describe('AgentLoop', () => { expect(events.some((event) => event.kind === 'context_snapshot')).toBe(false) }) - it('auto-compacts the 725733 + 131072 dead zone before the model transport instead of failing', async () => { + it('does not compact below the soft threshold solely for a large output capability', async () => { const requests: ModelRequest[] = [] const h = makeHarness({ provider: 'deadzone', @@ -470,8 +455,9 @@ describe('AgentLoop', () => { await h.threadStore.upsert( createThreadRecord({ id: h.threadId, title: 'demo', workspace: '/tmp', model: 'deadzone' }) ) - // ~725k estimated input tokens: below the 750k soft threshold, but with - // the 131072 output budget the full request would exceed the 850k cap. + // ~725k estimated input tokens stays below the 750k soft threshold. The + // advertised 131072 capability must not be reserved in full; ordinary + // requests use the bounded 32768-token reservation. const chunk = '工'.repeat(6_050) for (let index = 0; index < 120; index += 1) { await h.sessionStore.appendItem(h.threadId, makeUserItem({ @@ -490,15 +476,13 @@ describe('AgentLoop', () => { await expect(h.loop.runTurn(h.threadId, h.turnId)).resolves.toBe('completed') expect(requests).toHaveLength(1) - expect(requests[0]?.history[0]).toMatchObject({ kind: 'compaction' }) + expect(requests[0]?.history[0]).toMatchObject({ kind: 'user_message' }) + expect(requests[0]?.maxTokens).toBe(32_768) const events = await h.sessionStore.loadEventsSince(h.threadId, 0) expect(events.some((event) => event.kind === 'error' && event.code === 'context_window_exceeded' )).toBe(false) - expect(events).toContainEqual(expect.objectContaining({ - kind: 'compaction_completed', - replacedTokens: expect.any(Number) - })) + expect(events.some((event) => event.kind === 'compaction_completed')).toBe(false) const compressed = events.find((event) => event.kind === 'pipeline_stage' && event.stage === 'input_compressed' ) @@ -506,13 +490,72 @@ describe('AgentLoop', () => { kind: 'pipeline_stage', stage: 'input_compressed', details: expect.objectContaining({ - outputBudgetTokens: 131_072, + outputBudgetTokens: 32_768, requestHardCapTokens: 850_000, fallbackCompactionAttempted: false }) }) }) + it('does not repeatedly compact retained history for a 256k / 500k capability profile', async () => { + const requests: ModelRequest[] = [] + const h = makeHarness({ + provider: 'pathological-output-profile', + model: 'grok-4.5', + async *stream(request): AsyncIterable { + requests.push(request) + yield { kind: 'completed', stopReason: 'stop' } + } + }, { + tools: [], + compactor: new ContextCompactor({ softThreshold: 192_000, hardThreshold: 217_600 }), + modelCapabilities: (model) => ({ + id: model, + inputModalities: ['text'], + outputModalities: ['text'], + supportsToolCalling: true, + contextWindowTokens: 256_000, + maxOutputTokens: 500_000, + messageParts: ['text'] + }) + }) + await h.threadStore.upsert( + createThreadRecord({ id: h.threadId, title: 'demo', workspace: '/tmp', model: 'grok-4.5' }) + ) + for (let index = 0; index < 40; index += 1) { + await h.sessionStore.appendItem(h.threadId, makeUserItem({ + id: `pathological_old_${index}`, + turnId: `pathological_old_turn_${index}`, + threadId: h.threadId, + text: '工'.repeat(5_000) + })) + } + const first = await h.turns.startTurn({ + threadId: h.threadId, + request: { prompt: 'first retained request' } + }) + h.turnId = first.turnId + + await expect(h.loop.runTurn(h.threadId, h.turnId)).resolves.toBe('completed') + const afterFirst = await h.sessionStore.loadEventsSince(h.threadId, 0) + expect(afterFirst.filter((event) => event.kind === 'compaction_completed')).toHaveLength(1) + + const second = await h.turns.startTurn({ + threadId: h.threadId, + request: { prompt: 'small follow-up after compaction' } + }) + h.turnId = second.turnId + await expect(h.loop.runTurn(h.threadId, h.turnId)).resolves.toBe('completed') + + const afterSecond = await h.sessionStore.loadEventsSince(h.threadId, 0) + expect(afterSecond.filter((event) => event.kind === 'compaction_completed')).toHaveLength(1) + const mainRequests = requests.filter((request) => request.systemPrompt !== COMPACTION_SYSTEM_PROMPT) + expect(mainRequests.at(-1)).toMatchObject({ maxTokens: 32_768 }) + expect(mainRequests.at(-1)?.history.some((item) => + item.kind === 'user_message' && item.text === 'small follow-up after compaction' + )).toBe(true) + }) + it('fails once with a detailed reason when the current message itself cannot be compacted', async () => { let dispatches = 0 const h = makeHarness({ @@ -561,7 +604,7 @@ describe('AgentLoop', () => { expect(events.some((event) => event.kind === 'compaction_completed')).toBe(false) }) - it('clamps the send-time output budget when rehydrated generated-image input grows', async () => { + it('keeps a large output capability bounded when generated-image input is rehydrated', async () => { const requests: ModelRequest[] = [] const h = makeHarness({ provider: 'image-fallback', @@ -588,11 +631,9 @@ describe('AgentLoop', () => { await h.threadStore.upsert( createThreadRecord({ id: h.threadId, title: 'demo', workspace: '/tmp', model: 'image-fallback' }) ) - // Preflight lands just below the soft threshold (718,127), while the - // rehydrated forwarded image adds a fixed 1,210-token vision allowance - // and pushes the final request past what the declared 131,072 output - // budget would allow. The send-time clamp shrinks the output budget to - // the remaining capacity (130,663) instead of compacting history. + // Preflight lands just below the soft threshold and image rehydration + // adds a fixed vision allowance. The 131,072 model capability remains a + // 32,768-token ordinary reservation, so history stays intact. for (let index = 0; index < 119; index += 1) { await h.sessionStore.appendItem(h.threadId, makeUserItem({ id: `image_old_${index}`, @@ -632,9 +673,9 @@ describe('AgentLoop', () => { await expect(h.loop.runTurn(h.threadId, h.turnId)).resolves.toBe('completed') expect(requests).toHaveLength(1) - // History stays intact: the clamp absorbed the 1,210-token overage. + // History stays intact because the advertised maximum is not reserved. expect(requests[0]?.history[0]).toMatchObject({ kind: 'user_message' }) - expect(requests[0]?.maxTokens).toBe(130_494) + expect(requests[0]?.maxTokens).toBe(32_768) const events = await h.sessionStore.loadEventsSince(h.threadId, 0) expect(events.some((event) => event.kind === 'error' && event.code === 'context_window_exceeded' @@ -647,7 +688,7 @@ describe('AgentLoop', () => { kind: 'pipeline_stage', stage: 'input_compressed', details: expect.objectContaining({ - outputBudgetTokens: 130_494, + outputBudgetTokens: 32_768, requestHardCapTokens: 850_000, fallbackCompactionAttempted: false }) diff --git a/kun/tests/runtime-factory.test.ts b/kun/tests/runtime-factory.test.ts index fe14d4508..25d89a4aa 100644 --- a/kun/tests/runtime-factory.test.ts +++ b/kun/tests/runtime-factory.test.ts @@ -156,6 +156,73 @@ describe('runtime factory usage carryover', () => { } }) + it('keeps explore_agent advertised across Lab hot-apply toggles', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'kun-runtime-explore-lab-')) + tempDirs.push(dataDir) + const runtime = await createKunServeRuntime({ + host: '127.0.0.1', + port: 0, + dataDir, + runtimeToken: 'tok', + apiKey: 'sk-default', + baseUrl: 'https://api.example.test/v1', + model: 'model-before', + approvalPolicy: 'auto', + sandboxMode: 'danger-full-access', + tokenEconomyMode: false, + insecure: false, + storage: { backend: 'file' }, + lab: { exploreAgent: { enabled: true, fast: false } }, + capabilities: KunCapabilitiesConfig.parse({ + subagents: { enabled: true } + }) + }) + + const listExplore = async () => { + const toolHost = runtime.toolHost + expect(toolHost).toBeDefined() + if (!toolHost) throw new Error('Expected the Kun runtime tool host to be available') + const tools = await toolHost.listTools({ + threadId: 'thr_explore', + turnId: 'turn_explore', + workspace: dataDir, + threadMode: 'agent', + clientSurface: 'gui', + approvalPolicy: 'auto', + abortSignal: new AbortController().signal, + awaitApproval: async () => 'allow' + }) + return tools.some((tool) => tool.name === 'explore_agent') + } + + try { + const diagnostics = await runtime.toolDiagnostics?.() + expect(diagnostics?.providers.some((provider) => provider.id === 'explore-agent')).toBe(true) + expect(await listExplore()).toBe(true) + + // Any hot-apply previously dropped explore_agent from the rebuilt registry. + expect(await runtime.applyConfig({ + capabilities: KunCapabilitiesConfig.parse({ + subagents: { enabled: true }, + web: { enabled: true, fetchEnabled: true } + }) + })).toEqual({ ok: true }) + expect(await listExplore()).toBe(true) + + expect(await runtime.applyConfig({ + lab: { exploreAgent: { enabled: false, fast: false } } + })).toEqual({ ok: true }) + expect(await listExplore()).toBe(false) + + expect(await runtime.applyConfig({ + lab: { exploreAgent: { enabled: true, fast: false } } + })).toEqual({ ok: true }) + expect(await listExplore()).toBe(true) + } finally { + await runtime.shutdown?.() + } + }) + it('keeps the recorder available while gating capture by each thread state', async () => { for (const [name, runtimeOptions, expectedCapture] of [ ['omitted', undefined, false], diff --git a/release/release-v0.2.37.md b/release/release-v0.2.37.md new file mode 100644 index 000000000..136b5be8c --- /dev/null +++ b/release/release-v0.2.37.md @@ -0,0 +1,45 @@ +# Kun v0.2.37 + +v0.2.37 重点完善探索型子代理(explore_agent)的只读调查链路与展示体验,同时加固 Agent Graph 并发、Provider 凭据和模型目录的稳定性,并补充自动更新、工具取消与用量计时等运行时能力。 + +### 探索型子代理 + +- 新增 `explore_agent` 只读探索工具,支持并行多路调查、Lab 配置和 Codex 快速执行路径;探索任务只读运行,不会修改工作区文件。 +- 探索结果在会话中呈现为可展开的 ExplorePeekBody 组件,SubagentCallCard 增加结论处理,消息时间线完整保留探索过程。 +- 工具调度策略与消息时间线补齐对 `explore_agent` 的专门处理;强化工具描述后,探索被确立为仓库调查的第一步。 +- `explore_agent` 会根据 Lab 设置动态决定是否对外暴露,未启用时不会增加无关的上下文与调用开销。 + +### Agent Graph 与并发稳定性 + +- Graph 增加线程所有权门禁,避免会话被错误跨线程接管;SSE 观察改为独立观察者,降低并发通知冲突。 +- 主导(lead)结算时保留已完成就绪的运行,避免结算顺序问题导致子代理结果被误判为失败。 +- 共享 Kun 运行时在发现进程 PID 失效后可以自动恢复,减少僵尸进程导致的连接异常。 + +### Provider、模型与凭据 + +- MiniMax 增加 OpenAI-compatible `/v1` endpoint 别名,兼容性模型接入更简单。 +- Provider 凭据保存改为保留脱敏凭据,避免异步保存把已填写的 API key 回退为空;空 primary model 不再覆盖当前生效的设置。 +- Token Plan 模型目录在注册表并发更新时保持稳定,不再因竞争而丢失。 +- 语音转文字设置增加凭据就绪检查;OpenCode Go 的 Chromium Cookie 读取、解密与订阅诊断进一步完善。 +- Cursor 集成重新暴露 Kun 独有工具,并排除重叠的 Cursor 内置工具;Codex responses URL 规范化,相关 endpoint 处理更一致。 + +### 工具、用量与可观测性 + +- 新增工具取消功能,长时间运行的工具可以主动中断。 +- 用量追踪增加计时指标,telemetry 压力管理简化,模型请求处理更稳定。 +- 线程快照增加缓存与状态管理,恢复会话时更快、更可靠。 + +### 更新、发布与 Office + +- 自动更新支持原地安装处理与更新残留清理,升级过程更干净。 +- Windows 下 `.cmd` 脚本改用 cmd.exe 执行,修复脚本类工具在 Windows 上的运行问题。 +- Office/WPS 文档导入时 schema 校验改为软失败,个别文档兼容性问题不再阻断导入。 + +### 升级说明 + +- 从 v0.2.36 升级无需手动迁移会话、工作区、Graph 或 Provider 配置。 +- 探索型子代理为新增能力,无需迁移历史数据;如未在 Lab 中启用,不会改变既有工作流。 + +### 完整变更 + +https://github.com/KunAgent/Kun/compare/v0.2.36...v0.2.37 diff --git a/scripts/run-with-kun-flavor.cjs b/scripts/run-with-kun-flavor.cjs index 437b87ed1..269db16cd 100644 --- a/scripts/run-with-kun-flavor.cjs +++ b/scripts/run-with-kun-flavor.cjs @@ -10,6 +10,9 @@ if ((flavor !== 'production' && flavor !== 'development') || !command) { const executable = process.platform === 'win32' && !/\.(?:cmd|exe)$/iu.test(command) ? `${command}.cmd` : command +// On Windows, .cmd batch scripts cannot be launched directly via CreateProcess; +// they must go through cmd.exe, otherwise spawnSync fails with EINVAL. +const needsCmdShell = process.platform === 'win32' && /\.cmd$/iu.test(executable) let electronCliArgs = [] try { const configured = JSON.parse(process.env.ELECTRON_CLI_ARGS || '[]') @@ -34,7 +37,8 @@ const result = spawnSync(executable, args, { : {}), ELECTRON_CLI_ARGS: JSON.stringify(electronCliArgs) }, - stdio: 'inherit' + stdio: 'inherit', + ...(needsCmdShell ? { shell: true } : {}) }) if (result.error) { diff --git a/scripts/smoke-windows-installer-migration.ps1 b/scripts/smoke-windows-installer-migration.ps1 index b7e784f50..c895f5862 100644 --- a/scripts/smoke-windows-installer-migration.ps1 +++ b/scripts/smoke-windows-installer-migration.ps1 @@ -482,12 +482,15 @@ try { Assert-True (-not (Test-Path -LiteralPath $attackerMarker)) 'The elevated update executed the tampered registry uninstaller.' $machineLocationAfterUpdate = Get-ItemPropertyValue -LiteralPath $machineInstallRegistryPath -Name InstallLocation Assert-True (Test-PathEqual $machineLocationAfterUpdate $machineTarget) 'The automatic update did not retain the all-users registration.' + Assert-True (Test-Path -LiteralPath (Join-Path $machineTarget 'Kun.exe')) 'The automatic update left the all-users application executable missing.' + Assert-True (Test-Path -LiteralPath (Join-Path $machineTarget 'resources\app.asar')) 'The automatic update left the all-users application payload incomplete.' $otherUserLocationAfterUpdate = Get-ItemPropertyValue -LiteralPath $otherUserInstallRegistryPath -Name InstallLocation Assert-True (Test-PathEqual $otherUserLocationAfterUpdate $otherUserTarget) 'The automatic update changed the unrelated current-user registration.' $otherUserUninstallAfterUpdate = Get-ItemPropertyValue -LiteralPath $otherUserUninstallRegistryPath -Name UninstallString Assert-True ($otherUserUninstallAfterUpdate -eq $otherUserUninstallString) 'The automatic update did not restore the unrelated current-user uninstall registration.' $automaticUpdateDiagnostics = Get-Content -LiteralPath $diagnosticPath -Raw Assert-True ($automaticUpdateDiagnostics -match [regex]::Escape("source=$machineTarget")) 'The automatic update did not validate the running all-users source.' + Assert-True ($automaticUpdateDiagnostics -match 'SUCCESS action=CleanupInPlaceLeftovers') 'The automatic update did not run post-validate in-place leftover cleanup.' Invoke-Uninstaller 'all-users uninstall' $machineTarget '/allusers' Assert-PathEntryRemoved $machineTarget diff --git a/src/main/index.ts b/src/main/index.ts index d8f35d534..47e788959 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -26,6 +26,7 @@ import { JsonSettingsStore, devServerHintUrl } from './settings-store' +import { preserveRedactedProviderCredentials } from './settings-credential-redaction' import kunLogoPng from '../asset/img/kun.png?url' import kunMacLogoPng from '../asset/img/kun_mac.png?url' import kunTrayPng from '../asset/img/kun_tray.png?url' @@ -3229,7 +3230,10 @@ app.whenReady().then(async () => { const { previous, saved } = await runtimeSettingsIntents.serializePersistence(async () => { let committedPrevious: AppSettingsV1 | undefined const saved = await store.update((current) => { - const effectivePartial = preserveRuntimeTokenForFullSettingsSnapshot(current, partial) + const effectivePartial = preserveRedactedProviderCredentials( + current, + preserveRuntimeTokenForFullSettingsSnapshot(current, partial) + ) const requestedDataDir = effectivePartial.agents?.kun?.dataDir if ( appEnvironment.flavor === 'production' && @@ -3308,7 +3312,10 @@ app.whenReady().then(async () => { const saved = await runtimeSettingsIntents.serializePersistence(async () => { let committedPrevious: AppSettingsV1 | undefined const saved = await store.update((current) => { - const effectivePartial = preserveRuntimeTokenForFullSettingsSnapshot(current, partial) + const effectivePartial = preserveRedactedProviderCredentials( + current, + preserveRuntimeTokenForFullSettingsSnapshot(current, partial) + ) const requestedDataDir = effectivePartial.agents?.kun?.dataDir if ( appEnvironment.flavor === 'production' && diff --git a/src/main/ipc/app-ipc-schemas.test.ts b/src/main/ipc/app-ipc-schemas.test.ts index 7fa335930..e7054a108 100644 --- a/src/main/ipc/app-ipc-schemas.test.ts +++ b/src/main/ipc/app-ipc-schemas.test.ts @@ -6,6 +6,7 @@ import { conversationExportPayloadSchema, cursorSubscriptionDiscoveryPayloadSchema, isSafeOpenExternalUrl, + modelProviderCredentialRevealPayloadSchema, modelsDevCatalogPayloadSchema, notificationPayloadSchema, runtimeRequestPayloadSchema, @@ -63,6 +64,17 @@ describe('app-ipc-schemas', () => { expect(() => cursorSubscriptionDiscoveryPayloadSchema.parse({ apiKey: '' })).toThrow() }) + it('accepts only one bounded provider identity for credential reveal', () => { + expect(modelProviderCredentialRevealPayloadSchema.parse({ + providerId: ' deepseek ' + })).toEqual({ providerId: 'deepseek' }) + expect(() => modelProviderCredentialRevealPayloadSchema.parse({ providerId: '' })).toThrow() + expect(() => modelProviderCredentialRevealPayloadSchema.parse({ + providerId: 'deepseek', + credential: 'must-not-cross-the-request-boundary' + })).toThrow() + }) + it('accepts only provider identity and refresh fields for models.dev lookup', () => { expect(modelsDevCatalogPayloadSchema.parse({ providerId: 'xiaomi-token-plan', @@ -600,6 +612,20 @@ describe('app-ipc-schemas', () => { })).toThrow() }) + it('accepts clearing the provider while keeping the primary model non-empty', () => { + expect(settingsPatchSchema.parse({ + agents: { kun: { providerId: '' } } + }).agents?.kun).toEqual({ providerId: '' }) + const emptyModel = settingsPatchSchema.safeParse({ + agents: { kun: { model: '' } } + }) + expect(emptyModel.success).toBe(false) + if (!emptyModel.success) { + expect(emptyModel.error.issues[0]?.path).toEqual(['agents', 'kun', 'model']) + expect(emptyModel.error.issues[0]?.message).toMatch(/Too small/) + } + }) + it('accepts the cursor spotlight preference', () => { expect(settingsPatchSchema.parse({ cursorSpotlight: false }).cursorSpotlight).toBe(false) expect(settingsPatchSchema.parse({ cursorSpotlightColor: ' #FF8800 ' }).cursorSpotlightColor).toBe('#FF8800') diff --git a/src/main/ipc/app-ipc-schemas/runtime.ts b/src/main/ipc/app-ipc-schemas/runtime.ts index d6b4aa4ad..3cf963c78 100644 --- a/src/main/ipc/app-ipc-schemas/runtime.ts +++ b/src/main/ipc/app-ipc-schemas/runtime.ts @@ -42,8 +42,10 @@ import { KUN_THREAD_TURN_TEMPLATE, KUN_THREAD_TURNS_TEMPLATE, KUN_THREAD_INTERRUPT_TEMPLATE, + KUN_THREAD_TOOL_CANCEL_TEMPLATE, KUN_THREAD_MODEL_REQUESTS_TEMPLATE, KUN_THREAD_STEER_TEMPLATE, + KUN_THREAD_STATE_TEMPLATE, KUN_THREAD_TEMPLATE, KUN_USER_INPUT_TEMPLATE, KUN_USAGE_TEMPLATE, @@ -95,6 +97,12 @@ export const providerProbePayloadSchema = z }) .strict() +export const modelProviderCredentialRevealPayloadSchema = z + .object({ + providerId: trimmedString(128) + }) + .strict() + export const modelsDevCatalogPayloadSchema = z .object({ providerId: trimmedString(128), @@ -171,6 +179,7 @@ const ENDPOINTS: readonly EndpointTemplate[] = [ compileEndpoint(KUN_MCP_OAUTH_TEMPLATE, ['GET', 'DELETE']), compileEndpoint(KUN_MCP_OAUTH_SERVER_TEMPLATE, ['DELETE']), compileEndpoint(KUN_THREADS_TEMPLATE, ['GET', 'POST']), + compileEndpoint(KUN_THREAD_STATE_TEMPLATE, ['GET']), compileEndpoint(KUN_THREAD_TEMPLATE, ['GET', 'PATCH', 'DELETE']), compileEndpoint(KUN_THREAD_FORK_TEMPLATE, ['POST']), compileEndpoint(KUN_THREAD_GOAL_TEMPLATE, ['GET', 'POST', 'DELETE']), @@ -182,6 +191,7 @@ const ENDPOINTS: readonly EndpointTemplate[] = [ compileEndpoint(KUN_THREAD_TURN_TEMPLATE, ['GET']), compileEndpoint(KUN_THREAD_STEER_TEMPLATE, ['POST']), compileEndpoint(KUN_THREAD_INTERRUPT_TEMPLATE, ['POST']), + compileEndpoint(KUN_THREAD_TOOL_CANCEL_TEMPLATE, ['POST']), compileEndpoint(KUN_THREAD_MODEL_REQUESTS_TEMPLATE, ['GET']), compileEndpoint(KUN_USER_INPUT_TEMPLATE, ['POST']), compileEndpoint(KUN_SESSION_RESUME_TEMPLATE, ['POST']), diff --git a/src/main/ipc/register-app-ipc-handlers.test.ts b/src/main/ipc/register-app-ipc-handlers.test.ts index 9ed01c78a..52750f3f9 100644 --- a/src/main/ipc/register-app-ipc-handlers.test.ts +++ b/src/main/ipc/register-app-ipc-handlers.test.ts @@ -666,6 +666,18 @@ describe('registerAppIpcHandlers', () => { expect(applySettingsPatch).not.toHaveBeenCalled() }) + it('includes the Zod path when settings:set rejects an empty primary model', async () => { + const applySettingsPatch = vi.fn(async () => settings()) + + registerAppIpcHandlers(registerOptions({ applySettingsPatch })) + + const handler = handlers.get('settings:set') + await expect( + handler?.({}, { agents: { kun: { model: '' } } }) + ).rejects.toThrow(/Invalid payload for settings:set: agents\.kun\.model: Too small/) + expect(applySettingsPatch).not.toHaveBeenCalled() + }) + it('redacts plaintext model credentials from settings:get without mutating the Main snapshot', async () => { const current = settingsWithPlaintextModelCredentials() const original = JSON.stringify(current) @@ -696,6 +708,58 @@ describe('registerAppIpcHandlers', () => { expect(JSON.stringify(persisted)).toBe(original) }) + it('reveals only the requested provider credential to the trusted workbench', async () => { + const projected = settingsWithPlaintextModelCredentials() + const providerId = projected.provider.providers[0]!.id + const stored: AppSettingsV1 = { + ...projected, + provider: { + ...projected.provider, + apiKey: '', + providers: projected.provider.providers.map((provider) => ({ + ...provider, + apiKey: '' + })) + } + } + const mainFrame = { processId: 10, routingId: 20 } + const contents = { id: 7, mainFrame } + const mainWindow = { isDestroyed: () => false, webContents: contents } + const trustedEvent = { sender: contents, senderFrame: mainFrame } + const withRegistryCredentials = vi.fn(async () => projected) + registerAppIpcHandlers(registerOptions({ + store: { load: vi.fn(async () => stored) } as never, + getMainWindow: () => mainWindow as never, + withRegistryCredentials + })) + + await expect(handlers.get('model-provider:credential:reveal')?.( + trustedEvent, + { providerId } + )).resolves.toEqual({ providerId, credential: 'provider-secret-0' }) + expect(withRegistryCredentials).toHaveBeenCalledOnce() + }) + + it('rejects untrusted provider credential reveal before loading protected settings', async () => { + const mainFrame = { processId: 10, routingId: 20 } + const contents = { id: 7, mainFrame } + const mainWindow = { isDestroyed: () => false, webContents: contents } + const storeLoad = vi.fn(async () => settings()) + const withRegistryCredentials = vi.fn(async (value: AppSettingsV1) => value) + registerAppIpcHandlers(registerOptions({ + store: { load: storeLoad } as never, + getMainWindow: () => mainWindow as never, + withRegistryCredentials + })) + + await expect(handlers.get('model-provider:credential:reveal')?.( + { sender: { id: 99 }, senderFrame: { processId: 90, routingId: 91 } }, + { providerId: 'deepseek' } + )).rejects.toThrow(/trusted workbench frame/) + expect(storeLoad).not.toHaveBeenCalled() + expect(withRegistryCredentials).not.toHaveBeenCalled() + }) + it('requires trusted native confirmation before resetting unreadable credentials', async () => { const mainFrame = { processId: 10, routingId: 20 } const contents = { id: 7, mainFrame } diff --git a/src/main/ipc/register-app-ipc-handlers.ts b/src/main/ipc/register-app-ipc-handlers.ts index daffe2c2d..f184a2df3 100644 --- a/src/main/ipc/register-app-ipc-handlers.ts +++ b/src/main/ipc/register-app-ipc-handlers.ts @@ -37,6 +37,7 @@ import type { ConversationWorkspaceCreateResult, DesktopCommand, KunRuntimeSettingsSyncStatusPayload, + ModelProviderCredentialRevealResult, RuntimeRequestResult, SystemNotificationResult, TurnCompleteNotificationPayload, @@ -69,6 +70,7 @@ import { notificationPayloadSchema, openEditorPathPayloadSchema, modelsDevCatalogPayloadSchema, + modelProviderCredentialRevealPayloadSchema, providerProbePayloadSchema, projectDesignMdLintPayloadSchema, promptOptimizationPayloadSchema, @@ -432,11 +434,23 @@ function approvalLogReference(approvalId: string): string { return `sha256:${createHash('sha256').update(approvalId).digest('hex').slice(0, 16)}` } +function formatZodIssuePath(path: readonly PropertyKey[]): string { + return path + .map((segment) => typeof segment === 'symbol' ? segment.toString() : String(segment)) + .join('.') +} + function parseIpcPayload(channel: string, schema: z.ZodType, payload: unknown): T { const parsed = schema.safeParse(payload) if (parsed.success) return parsed.data const issue = parsed.error.issues[0] - throw new Error(`Invalid payload for ${channel}: ${issue?.message ?? 'Bad request.'}`) + const message = issue?.message ?? 'Bad request.' + const path = issue?.path?.length ? formatZodIssuePath(issue.path) : '' + throw new Error( + path + ? `Invalid payload for ${channel}: ${path}: ${message}` + : `Invalid payload for ${channel}: ${message}` + ) } function withoutRendererProjectConfigGrants(partial: AppSettingsPatch): AppSettingsPatch { @@ -918,6 +932,27 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions): ipcMain.handle('settings:get', async () => withoutRendererPlaintextCredentials(await store.load()) ) + ipcMain.handle( + 'model-provider:credential:reveal', + async (event, payload: unknown): Promise => { + assertTrustedWorkbenchSender(event, getMainWindow) + const { providerId } = parseIpcPayload( + 'model-provider:credential:reveal', + modelProviderCredentialRevealPayloadSchema, + payload + ) + const stored = await store.load() + if (!stored.provider.providers.some((provider) => provider.id === providerId)) { + throw new Error(`Provider profile "${providerId}" is unavailable`) + } + const projected = await withRegistryCredentials(stored) + const credential = projected.provider.providers + .find((provider) => provider.id === providerId) + ?.apiKey.trim() ?? '' + if (!credential) throw new Error('Protected provider credential is unavailable') + return { providerId, credential } + } + ) ipcMain.handle('credentials:reset-unreadable', async (event): Promise => { assertTrustedWorkbenchSender(event, getMainWindow) const parent = getMainWindow() diff --git a/src/main/kun-runtime-supervisor.test.ts b/src/main/kun-runtime-supervisor.test.ts index 6564ff90a..a9f799a7a 100644 --- a/src/main/kun-runtime-supervisor.test.ts +++ b/src/main/kun-runtime-supervisor.test.ts @@ -184,6 +184,26 @@ describe('KunRuntimeSupervisor', () => { expect(h.statuses.map((status) => status.state)).toEqual(['restarting', 'running']) }) + it('takes the missing/ensure path when health clears a stale childRunning cache (#1116)', async () => { + let childRunning = true + const h = harness({ + childRunning: true, + watchdogFailureThreshold: 3 + }) + h.deps.isChildRunning = () => childRunning + h.checkHealth.mockImplementation(async () => { + childRunning = false + return false + }) + h.supervisor.setManagedRuntimeExpected(true) + + await h.supervisor.watchdogTick() + + expect(h.ensureRuntime).toHaveBeenCalledOnce() + expect(h.restartRuntime).not.toHaveBeenCalled() + expect(h.statuses.map((status) => status.state)).toEqual(['restarting', 'running']) + }) + it('does not recover or restart after shutdown begins', async () => { const h = harness({ stopped: true }) h.supervisor.setManagedRuntimeExpected(true) diff --git a/src/main/kun-runtime-supervisor.ts b/src/main/kun-runtime-supervisor.ts index 20346cbd9..5a8d1f28d 100644 --- a/src/main/kun-runtime-supervisor.ts +++ b/src/main/kun-runtime-supervisor.ts @@ -247,12 +247,16 @@ export class KunRuntimeSupervisor { const settings = await this.deps.loadSettings() if (!this.managedRuntimeExpected || !this.deps.canAutoRestart(settings)) return - const childRunning = this.deps.isChildRunning() + let childRunning = this.deps.isChildRunning() if (childRunning && await this.deps.checkHealth(settings, 5_000)) { this.noteHealthy('watchdog') return } + // checkHealth refreshes discovery; a dead shared-runtime PID may have been + // cleared from the adapter cache. Re-read before counting unresponsive + // failures so recovery can take the missing/ensure path (#1116). + childRunning = this.deps.isChildRunning() if (!childRunning) { await this.recoverFromWatchdog('missing') return diff --git a/src/main/legacy-provider-settings-migration.ts b/src/main/legacy-provider-settings-migration.ts index 05b147769..34822acb1 100644 --- a/src/main/legacy-provider-settings-migration.ts +++ b/src/main/legacy-provider-settings-migration.ts @@ -163,7 +163,8 @@ export class LegacyProviderSettingsMigrationCoordinator { /** * Produces a short-lived Main-only settings view for legacy request paths. - * Registry credentials never enter ordinary settings or renderer IPC. + * Registry credentials never enter ordinary settings or bulk renderer IPC; + * the trusted workbench may request one provider explicitly for UI reveal. */ async withRegistryCredentials(settings: AppSettingsV1): Promise { const dataDir = resolveSettingsDataDir(settings) diff --git a/src/main/models-dev-catalog.test.ts b/src/main/models-dev-catalog.test.ts index 72051f0a9..771ebd216 100644 --- a/src/main/models-dev-catalog.test.ts +++ b/src/main/models-dev-catalog.test.ts @@ -199,8 +199,12 @@ describe('resolveModelsDevProvider', () => { ['xiaomi-token-plan', 'https://token-plan-cn.xiaomimimo.com/v1/', 'xiaomi-token-plan-cn', 'catalog'], ['xiaomi-token-plan', 'https://token-plan-sgp.xiaomimimo.com/v1', 'xiaomi-token-plan-sgp', 'catalog'], ['xiaomi-token-plan', 'https://token-plan-ams.xiaomimimo.com/v1', 'xiaomi-token-plan-ams', 'catalog'], + ['minimax-token-plan', 'https://api.minimaxi.com/v1', 'minimax-cn-coding-plan', 'catalog'], + ['minimax-token-plan', 'https://api.minimax.io/v1', 'minimax-coding-plan', 'catalog'], ['minimax-token-plan', 'https://api.minimax.io/anthropic', 'minimax-coding-plan', 'catalog'], ['minimax-token-plan', 'https://api.minimaxi.com/anthropic', 'minimax-cn-coding-plan', 'catalog'], + ['minimax', 'https://api.minimaxi.com/v1', 'minimax-cn', 'catalog'], + ['minimax', 'https://api.minimax.io/v1', 'minimax', 'catalog'], ['minimax', 'https://api.minimaxi.com/anthropic', 'minimax-cn', 'catalog'], ['minimax', 'https://api.minimax.io/anthropic', 'minimax', 'catalog'], ['aliyun', 'https://dashscope.aliyuncs.com/compatible-mode/v1', 'alibaba-cn', 'catalog'], diff --git a/src/main/models-dev-catalog.ts b/src/main/models-dev-catalog.ts index f7038a8c6..f480215eb 100644 --- a/src/main/models-dev-catalog.ts +++ b/src/main/models-dev-catalog.ts @@ -106,6 +106,10 @@ const XIAOMI_TOKEN_PLAN_URLS = urlMatchMap({ }) const MINIMAX_URLS = urlMatchMap({ + 'https://api.minimaxi.com/v1': 'minimax-cn', + 'https://api.minimaxi.com/v1/': 'minimax-cn', + 'https://api.minimax.io/v1': 'minimax', + 'https://api.minimax.io/v1/': 'minimax', 'https://api.minimaxi.com/anthropic': 'minimax-cn', 'https://api.minimaxi.com/anthropic/v1': 'minimax-cn', 'https://api.minimax.io/anthropic': 'minimax', @@ -113,6 +117,10 @@ const MINIMAX_URLS = urlMatchMap({ }) const MINIMAX_TOKEN_PLAN_URLS = urlMatchMap({ + 'https://api.minimaxi.com/v1': 'minimax-cn-coding-plan', + 'https://api.minimaxi.com/v1/': 'minimax-cn-coding-plan', + 'https://api.minimax.io/v1': 'minimax-coding-plan', + 'https://api.minimax.io/v1/': 'minimax-coding-plan', 'https://api.minimaxi.com/anthropic': 'minimax-cn-coding-plan', 'https://api.minimaxi.com/anthropic/v1': 'minimax-cn-coding-plan', 'https://api.minimax.io/anthropic': 'minimax-coding-plan', @@ -137,8 +145,9 @@ const ENRICHMENT_ONLY_URL_MATCHES = new Map([ ]) // URL fallback is intentionally limited to unambiguous public endpoints. -// MiniMax's regular API and Token Plan share the same URLs, so those entries -// require a known Kun profile id and are excluded here. +// MiniMax exposes both OpenAI-compatible and Anthropic-compatible URLs for +// the regular API and Token Plan, so those entries require a known Kun profile +// id and are excluded here. const UNAMBIGUOUS_URL_MATCHES = urlMatchMap({ 'https://api.deepseek.com': 'deepseek', 'https://api.longcat.chat/openai': 'longcat', diff --git a/src/main/packaging-config.test.ts b/src/main/packaging-config.test.ts index 5144169b8..e3ca3579c 100644 --- a/src/main/packaging-config.test.ts +++ b/src/main/packaging-config.test.ts @@ -566,6 +566,17 @@ describe('electron-builder Kun packaging', () => { 'DeleteRegKey HKEY_CURRENT_USER "${INSTALL_REGISTRY_KEY}"' ) expect(installerScript).toContain('KunHandleOldUninstallerResult') + expect(installerScript).toContain('Var /GLOBAL KunInstallerInPlaceUpdate') + expect(installerScript).toContain('Function KunMarkInPlaceAutomaticUpdate') + expect(installerScript).toContain('${if} $KunInstallerInPlaceUpdate == 1') + expect(installerScript).toContain( + 'skipping pre-install removal of $KunInstallerPrimarySourceDir' + ) + expect(installerScript).toContain( + 'suppressed the selected-scope uninstaller until the new payload is installed' + ) + expect(installerScript).toContain('!insertmacro kunRunMigrationHelper CleanupInPlaceLeftovers') + expect(installerScript).toContain('KUN_INSTALLER_IN_PLACE_UPDATE') expect(installerScript).toContain('Function KunSecureSelectedUninstallRegistration') expect(installerScript).toContain('Function KunSecureCurrentUserUninstallRegistration') expect(installerScript).toContain('!insertmacro kunRunMigrationHelper ResolveUninstaller') @@ -590,6 +601,10 @@ describe('electron-builder Kun packaging', () => { expect(installerScript).not.toContain('Stop-Process -Id') expect(migrationScript).toContain("'ResolveUpdateScope', 'ResolveUninstaller', 'StopProcesses'") + expect(migrationScript).toContain("'CleanupInPlaceLeftovers', 'UpdatePath'") + expect(migrationScript).toContain('function Invoke-CleanupInPlaceLeftovers') + expect(migrationScript).toContain('function Test-RetainedInPlaceKnownEntry') + expect(migrationScript).toContain("Get-EnvironmentValue 'KUN_INSTALLER_IN_PLACE_UPDATE'") expect(migrationScript).not.toContain("'old-uninstaller.exe'") expect(migrationScript).toContain("Join-Path $PSScriptRoot 'kun-windows-installer-result.txt'") expect(migrationScript).toContain('function Test-AppOwnedProcessPath') diff --git a/src/main/provider-subscription-quota.ts b/src/main/provider-subscription-quota.ts index d39f2a231..1d8057afa 100644 --- a/src/main/provider-subscription-quota.ts +++ b/src/main/provider-subscription-quota.ts @@ -15,6 +15,10 @@ import { type OpenCodeGoLocalQuotaResult } from '../../kun/src/services/opencode-go-local-quota.js' import { + clearOpenCodeGoCookieCache, + getOpenCodeGoCookieFailureReason, + OPENCODE_GO_KEYCHAIN_MESSAGE, + OPENCODE_GO_SIGN_IN_MESSAGE, resolveOpenCodeGoCookie as resolveOpenCodeGoCookieImpl } from '../../kun/src/services/provider-subscription-quota.js' import { @@ -223,21 +227,37 @@ export async function runSubscriptionQuotaProbe( return probeGoogleCodeAssistQuota(credential, context, 'antigravity') } if (kind === 'opencode-go-local') { - const cookieHeader = await runtime.resolveOpenCodeGoCookie() + const tryWeb = async (cookieHeader: string) => { + const web = await runtime.fetchOpenCodeGoWebQuota(cookieHeader, context) + if (web.metrics.length > 0) { + return { + metrics: web.metrics, + ...(web.summary ? { summary: web.summary } : {}), + source: 'OpenCode Go subscription usage' + } as const + } + return undefined + } + + let cookieHeader = await runtime.resolveOpenCodeGoCookie() if (cookieHeader) { try { - const web = await runtime.fetchOpenCodeGoWebQuota(cookieHeader, context) - if (web.metrics.length > 0) { - return { - metrics: web.metrics, - ...(web.summary ? { summary: web.summary } : {}), - source: 'OpenCode Go subscription usage' - } - } + const web = await tryWeb(cookieHeader) + if (web) return web } catch (error) { - // Web quota is best-effort: any auth/network/parse failure falls back - // to the local usage database estimate. if (!(error instanceof OpenCodeGoWebQuotaError)) throw error + if (error.code === 'invalid_credentials') { + clearOpenCodeGoCookieCache() + cookieHeader = await runtime.resolveOpenCodeGoCookie() + if (cookieHeader) { + try { + const web = await tryWeb(cookieHeader) + if (web) return web + } catch (retryError) { + if (!(retryError instanceof OpenCodeGoWebQuotaError)) throw retryError + } + } + } } } const quota = await runtime.resolveOpenCodeGoQuota() @@ -248,7 +268,9 @@ export async function runSubscriptionQuotaProbe( } } throw new ProviderQuotaMissingCredentialError( - 'Sign in to opencode.ai in your browser, or use OpenCode Go locally first so its usage history exists.' + getOpenCodeGoCookieFailureReason() === 'decrypt_failed' + ? OPENCODE_GO_KEYCHAIN_MESSAGE + : OPENCODE_GO_SIGN_IN_MESSAGE ) } const accessToken = await runtime.resolveGeminiCliToken(context) diff --git a/src/main/runtime/kun-adapter.test.ts b/src/main/runtime/kun-adapter.test.ts index 4c71b07c4..b6a39e33c 100644 --- a/src/main/runtime/kun-adapter.test.ts +++ b/src/main/runtime/kun-adapter.test.ts @@ -23,7 +23,8 @@ import { resolveRuntimeRequestTimeoutMs, runtimeAuthHeaders, runtimeRequestViaHost, - runtimeRequestViaLease + runtimeRequestViaLease, + setResolvedKunRuntimeConnectionForTests } from './kun-adapter' import { buildRuntimeCapabilityManifest } from '../../../kun/src/contracts/capabilities.js' import { modelCapabilitiesForModel } from '../../../kun/src/loop/model-context-profile.js' @@ -481,3 +482,48 @@ describe('kunRuntimeAdapter.resolveConnection', () => { } }) }) + +describe('kunRuntimeAdapter.isChildRunning dead-PID recovery', () => { + afterEach(async () => { + setResolvedKunRuntimeConnectionForTests(null) + await kunRuntimeAdapter.stopAndWait() + }) + + it('clears a cached discovery record whose PID is no longer alive (#1116)', () => { + setResolvedKunRuntimeConnectionForTests({ + version: 1, + instanceId: 'dead-shared-runtime', + pid: 2_147_483_647, + startedAt: '2026-08-07T00:00:00.000Z', + host: '127.0.0.1', + port: 44793, + baseUrl: 'http://127.0.0.1:44793', + runtimeToken: 'stale-token', + insecure: false, + serviceVersion: '0.0.0-test', + launchMode: 'shared' + }) + + expect(kunRuntimeAdapter.isChildRunning()).toBe(false) + expect(kunRuntimeAdapter.getBaseUrl(settingsForPort(18788))).toBe('http://127.0.0.1:18788') + }) + + it('keeps reporting running while the cached discovery PID is alive', () => { + setResolvedKunRuntimeConnectionForTests({ + version: 1, + instanceId: 'live-shared-runtime', + pid: process.pid, + startedAt: '2026-08-07T00:00:00.000Z', + host: '127.0.0.1', + port: 44793, + baseUrl: 'http://127.0.0.1:44793', + runtimeToken: 'live-token', + insecure: false, + serviceVersion: '0.0.0-test', + launchMode: 'shared' + }) + + expect(kunRuntimeAdapter.isChildRunning()).toBe(true) + expect(kunRuntimeAdapter.getBaseUrl(settingsForPort(18788))).toBe('http://127.0.0.1:44793') + }) +}) diff --git a/src/main/runtime/kun-adapter.ts b/src/main/runtime/kun-adapter.ts index 3b1db766b..4b7fef8f4 100644 --- a/src/main/runtime/kun-adapter.ts +++ b/src/main/runtime/kun-adapter.ts @@ -68,7 +68,14 @@ export const kunRuntimeAdapter = { }, isChildRunning(): boolean { - return Boolean(resolvedConnection) || isKunChildRunning() + if (resolvedConnection) { + // Shared runtimes are detached; a cached discovery record can outlive the + // process. Treat a dead PID as "not running" so watchdog recovery takes the + // missing/ensure fast path instead of waiting out unresponsive retries (#1116). + if (processIsAlive(resolvedConnection.pid)) return true + resolvedConnection = null + } + return isKunChildRunning() }, getBaseUrl(settings: AppSettingsV1): string { @@ -167,6 +174,23 @@ function expandDataDir(value: string): string { return value.replace(/^~(?=$|[\\/])/, homedir()) } +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + // EPERM means the process exists but we cannot signal it. + return (error as NodeJS.ErrnoException)?.code === 'EPERM' + } +} + +/** Test-only: inject a cached discovery record for dead-PID recovery coverage. */ +export function setResolvedKunRuntimeConnectionForTests( + connection: RuntimeDiscoveryRecord | null +): void { + resolvedConnection = connection +} + export type RuntimeRequestInit = { method?: string body?: string diff --git a/src/main/services/office-document-service.test.ts b/src/main/services/office-document-service.test.ts index 1d19ef919..5c800e54e 100644 --- a/src/main/services/office-document-service.test.ts +++ b/src/main/services/office-document-service.test.ts @@ -6,7 +6,10 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { ZipFile } from 'yazl' import { MAX_RUNTIME_DOCUMENT_SOURCE_BYTES } from '../../shared/office-document' -import { readLocalOfficeDocument } from './office-document-service' +import { + isBenignOoxmlSchemaFailure, + readLocalOfficeDocument +} from './office-document-service' const roots: string[] = [] @@ -141,4 +144,113 @@ describe('Office document intake', () => { expect(result).toMatchObject({ ok: false, code: 'file_too_large' }) expect(runOfficeCli).not.toHaveBeenCalled() }) + + it('soft-fails WPS undeclared schema attributes and continues text extraction (#1122)', async () => { + const filePath = await ooxmlFixture('xlsx') + const wpsSchemaFailure = JSON.stringify({ + success: false, + data: { + count: 1, + errors: [{ + type: 'Schema', + description: + "The'http://www.wps.cn/officeDocument/2017/etCustomData:filterBottomFollowUsedRange' attribute is not declared.", + path: '/x:worksheet[1]/x:autoFilter[1]', + part: '/xl/worksheets/sheet1.xml' + }] + } + }) + const runOfficeCli = vi.fn(async (args: string[]) => { + if (args[0] === 'validate') { + return { stdout: wpsSchemaFailure, stderr: '', exitCode: 1 } + } + if (args[2] === 'stats') return { stdout: '{"sheetCount":1}', stderr: '', exitCode: 0 } + if (args[2] === 'html') return { stdout: 'WPS', stderr: '', exitCode: 0 } + return { stdout: 'Sheet1\\nA1 = ok', stderr: '', exitCode: 0 } + }) + + const result = await readLocalOfficeDocument({ path: filePath }, { + runOfficeCli, + renderHtml: vi.fn(async () => ({ + dataBase64: Buffer.from('p').toString('base64'), + mimeType: 'image/webp' as const, + byteSize: 1 + })) + }) + + expect(result).toMatchObject({ + ok: true, + format: 'xlsx', + documentText: expect.stringContaining('A1 = ok'), + validationWarning: expect.stringContaining('filterBottomFollowUsedRange') + }) + expect(runOfficeCli.mock.calls.map(([args]) => args[0])).toEqual([ + 'validate', + 'view', + 'view', + 'view' + ]) + }) + + it('still rejects non-schema OfficeCLI validate failures', async () => { + const filePath = await ooxmlFixture('xlsx') + const runOfficeCli = vi.fn(async (args: string[]) => { + if (args[0] === 'validate') { + return { + stdout: JSON.stringify({ + success: false, + data: { + count: 1, + errors: [{ type: 'Package', description: 'Missing required part /xl/workbook.xml' }] + } + }), + stderr: '', + exitCode: 1 + } + } + return { stdout: 'should-not-run', stderr: '', exitCode: 0 } + }) + + const result = await readLocalOfficeDocument({ path: filePath }, { + runOfficeCli, + renderHtml: vi.fn() + }) + + expect(result).toMatchObject({ + ok: false, + code: 'office_document_failed', + message: expect.stringContaining('Office document validation failed') + }) + expect(runOfficeCli).toHaveBeenCalledTimes(1) + }) +}) + +describe('isBenignOoxmlSchemaFailure', () => { + it('accepts Schema undeclared-attribute errors from WPS packages', () => { + expect(isBenignOoxmlSchemaFailure({ + exitCode: 1, + stdout: JSON.stringify({ + success: false, + data: { + errors: [{ + type: 'Schema', + description: + "The'http://www.wps.cn/officeDocument/2017/etCustomData:filterBottomFollowUsedRange' attribute is not declared." + }] + } + }), + stderr: '' + })).toBe(true) + }) + + it('rejects package-structure validate failures', () => { + expect(isBenignOoxmlSchemaFailure({ + exitCode: 1, + stdout: JSON.stringify({ + success: false, + data: { errors: [{ type: 'Package', description: 'Corrupt ZIP central directory' }] } + }), + stderr: '' + })).toBe(false) + }) }) diff --git a/src/main/services/office-document-service.ts b/src/main/services/office-document-service.ts index aa7c2339e..e47f13c2d 100644 --- a/src/main/services/office-document-service.ts +++ b/src/main/services/office-document-service.ts @@ -84,7 +84,16 @@ export async function readLocalOfficeDocument( )) const validation = await run(['validate', filePath, '--json']) - assertOfficeCliSuccess(validation, 'Office document validation failed') + let validationWarning: string | undefined + if (validation.exitCode !== 0) { + if (!isBenignOoxmlSchemaFailure(validation)) { + assertOfficeCliSuccess(validation, 'Office document validation failed') + } + // Vendor extensions (notably WPS etCustomData attrs) fail strict OpenXML + // schema checks but still extract cleanly. Keep intake open and surface a + // warning instead of forcing a local tool-reference fallback (#1122). + validationWarning = summarizeOfficeCliFailure(validation, 'Office document validation warning') + } const semanticArgs = semanticViewArgs(filePath, format) const semantic = await run(semanticArgs) @@ -130,7 +139,8 @@ export async function readLocalOfficeDocument( ...(pageCount ? { pageCount } : {}), truncated, ...(visualPreview ? { visualPreview } : {}), - ...(previewUnavailableReason ? { previewUnavailableReason } : {}) + ...(previewUnavailableReason ? { previewUnavailableReason } : {}), + ...(validationWarning ? { validationWarning } : {}) } } catch (error) { return { ok: false, code: 'office_document_failed', message: boundedErrorMessage(error) } @@ -145,8 +155,57 @@ function semanticViewArgs(filePath: string, format: OfficeDocumentFormat): strin function assertOfficeCliSuccess(result: OfficeCliResult, fallback: string): void { if (result.exitCode === 0) return + throw new Error(summarizeOfficeCliFailure(result, fallback)) +} + +function summarizeOfficeCliFailure(result: OfficeCliResult, fallback: string): string { const detail = result.stderr.trim() || result.stdout.trim() - throw new Error(detail ? `${fallback}: ${detail}` : fallback) + return detail ? `${fallback}: ${detail}` : fallback +} + +/** + * Intake-only: strict OpenXML schema rejects common vendor extensions (WPS + * etCustomData attributes, undeclared Ignorable attrs). Those documents still + * yield usable text via `view`. Reject anything that is not a Schema / undeclared + * markup failure so corrupted packages stay blocked. + */ +export function isBenignOoxmlSchemaFailure(result: OfficeCliResult): boolean { + if (result.exitCode === 0) return false + const payload = result.stdout.trim() || result.stderr.trim() + if (!payload) return false + const errors = extractOfficeValidateErrors(payload) + if (!errors || errors.length === 0) { + // Fallback when officecli prints a flat message without --json structure. + return /not declared|undeclared|schema/i.test(payload) && + /wps\.cn|etCustomData|officeDocument\/2017/i.test(payload) + } + return errors.every((error) => isBenignOoxmlSchemaError(error)) +} + +function extractOfficeValidateErrors(payload: string): Array> | null { + try { + const parsed = JSON.parse(payload) as unknown + if (!parsed || typeof parsed !== 'object') return null + const root = parsed as Record + const data = root.data && typeof root.data === 'object' + ? root.data as Record + : root + const errors = data.errors + if (!Array.isArray(errors)) return null + return errors.filter((entry): entry is Record => + Boolean(entry) && typeof entry === 'object' + ) + } catch { + return null + } +} + +function isBenignOoxmlSchemaError(error: Record): boolean { + const type = typeof error.type === 'string' ? error.type : '' + const description = typeof error.description === 'string' ? error.description : '' + const combined = `${type} ${description}` + if (!/schema/i.test(type) && !/schema/i.test(description)) return false + return /not declared|undeclared|wps\.cn|etCustomData|officeDocument\/2017/i.test(combined) } async function runOfficeCli( diff --git a/src/main/services/speech-to-text-service.test.ts b/src/main/services/speech-to-text-service.test.ts index 114833d46..69018738f 100644 --- a/src/main/services/speech-to-text-service.test.ts +++ b/src/main/services/speech-to-text-service.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import type { AppSettingsV1 } from '../../shared/app-settings' import { isSpeechToTextConfigured } from '../../shared/speech-to-text' -import { requestSpeechTranscription } from './speech-to-text-service' +import { + requestSpeechTranscription, + resolveSpeechToTextForTranscription +} from './speech-to-text-service' const AUDIO_BASE64 = Buffer.from('fake-wav-bytes').toString('base64') @@ -52,6 +55,65 @@ describe('speech-to-text service', () => { expect(isSpeechToTextConfigured({ enabled: true, protocol: 'local-whisper', baseUrl: '', apiKey: '', model: 'whisper-small-q5_1' })).toBe(true) expect(isSpeechToTextConfigured({ enabled: true, protocol: 'xai-stt', baseUrl: 'x', apiKey: 'y', model: '' })).toBe(true) expect(isSpeechToTextConfigured({ enabled: true, protocol: 'gemini-cli-audio', baseUrl: '', apiKey: '', model: 'gemini-2.5-flash' })).toBe(true) + expect(isSpeechToTextConfigured( + { enabled: true, protocol: 'xai-stt', baseUrl: 'https://api.x.ai/v1', apiKey: '', model: 'grok-transcribe' }, + { credentialReady: true } + )).toBe(true) + expect(isSpeechToTextConfigured( + { enabled: true, protocol: 'xai-stt', baseUrl: 'https://api.x.ai/v1', apiKey: '', model: 'grok-transcribe' }, + { credentialReady: false } + )).toBe(false) + }) + + it('falls back to Registry-projected apiKey when the renderer payload is redacted', async () => { + const { fetchImpl, requests } = fakeFetch({ text: 'hello from Grok' }) + const result = await requestSpeechTranscription( + settingsWithSpeech({ + protocol: 'xai-stt', + baseUrl: 'https://api.x.ai/v1', + apiKey: 'registry-oauth-json', + model: 'grok-transcribe' + }), + { + audioBase64: AUDIO_BASE64, + mimeType: 'audio/wav', + speechToText: { + enabled: true, + providerId: 'grok-subscription', + protocol: 'xai-stt', + baseUrl: 'https://api.x.ai/v1', + apiKey: '', + model: 'grok-transcribe', + localWhisperDownloadSource: 'huggingface', + language: '', + timeoutMs: 30000 + } + }, + { fetchImpl } + ) + + expect(result).toEqual({ ok: true, text: 'hello from Grok' }) + const headers = requests[0].init.headers as Record + expect(headers.Authorization).toBe('Bearer registry-oauth-json') + expect(resolveSpeechToTextForTranscription( + settingsWithSpeech({ + protocol: 'xai-stt', + baseUrl: 'https://api.x.ai/v1', + apiKey: 'registry-oauth-json', + model: 'grok-transcribe' + }), + { + enabled: true, + providerId: 'grok-subscription', + protocol: 'xai-stt', + baseUrl: 'https://api.x.ai/v1', + apiKey: '', + model: 'grok-transcribe', + localWhisperDownloadSource: 'huggingface', + language: '', + timeoutMs: 30000 + } + ).apiKey).toBe('registry-oauth-json') }) it('transcribes via MiMo ASR chat completions with a base64 data URI', async () => { diff --git a/src/main/services/speech-to-text-service.ts b/src/main/services/speech-to-text-service.ts index 328b0e371..11334ab03 100644 --- a/src/main/services/speech-to-text-service.ts +++ b/src/main/services/speech-to-text-service.ts @@ -35,6 +35,24 @@ const XAI_FORMAT_LANGUAGE_CODES = new Set([ 'mk', 'ms', 'fa', 'pl', 'pt', 'ro', 'ru', 'es', 'sv', 'th', 'tr', 'vi' ]) +/** + * Merge renderer-provided speech settings with Main's credential-projected + * settings. Renderer `settings:get` redacts provider apiKeys, so an empty + * request apiKey must fall back to the Registry-injected value. + */ +export function resolveSpeechToTextForTranscription( + settings: AppSettingsV1, + requestSpeechToText?: KunSpeechToTextSettingsV1 +): KunSpeechToTextSettingsV1 { + const resolved = resolveKunSpeechToTextSettings(settings) + if (!requestSpeechToText) return resolved + return { + ...resolved, + ...requestSpeechToText, + apiKey: requestSpeechToText.apiKey.trim() || resolved.apiKey + } +} + export async function requestSpeechTranscription( settings: AppSettingsV1, request: SpeechTranscriptionRequest, @@ -50,7 +68,7 @@ export async function requestSpeechTranscription( ) => Promise } = {} ): Promise { - const speechToText = request.speechToText ?? resolveKunSpeechToTextSettings(settings) + const speechToText = resolveSpeechToTextForTranscription(settings, request.speechToText) if (!isSpeechToTextConfigured(speechToText)) { return { ok: false, message: describeSpeechConfigurationIssue(speechToText) } } diff --git a/src/main/settings-credential-redaction.test.ts b/src/main/settings-credential-redaction.test.ts new file mode 100644 index 000000000..5f21568e8 --- /dev/null +++ b/src/main/settings-credential-redaction.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import { + defaultKunRuntimeSettings, + defaultModelProviderSettings, + type AppSettingsV1 +} from '../shared/app-settings' +import { preserveRedactedProviderCredentials } from './settings-credential-redaction' + +function settingsWithSecrets(): AppSettingsV1 { + const provider = defaultModelProviderSettings() + const deepseek = provider.providers.find((item) => item.id === 'deepseek') ?? provider.providers[0]! + return { + version: 1, + initialSetupCompleted: true, + locale: 'en', + theme: 'system', + provider: { + ...provider, + apiKey: 'top-level-secret', + providers: [ + { ...deepseek, apiKey: 'deepseek-secret' }, + { + id: 'opencode-go', + name: 'OpenCode Go', + apiKey: 'opencode-secret', + baseUrl: 'https://opencode.ai/zen/go/v1', + endpointFormat: 'chat_completions', + models: ['grok-4.5'], + modelProfiles: {} + } + ] + }, + agents: { + kun: { + ...defaultKunRuntimeSettings(), + apiKey: 'kun-runtime-secret', + providerId: 'opencode-go', + model: 'grok-4.5' + } + } + } as AppSettingsV1 +} + +describe('preserveRedactedProviderCredentials', () => { + it('restores hydrated provider secrets when the renderer sends redacted empty apiKeys', () => { + const prev = settingsWithSecrets() + const preserved = preserveRedactedProviderCredentials(prev, { + provider: { + providers: prev.provider.providers.map((provider) => ({ + ...provider, + apiKey: '' + })), + apiKey: '' + }, + agents: { + kun: { + providerId: 'opencode-go', + apiKey: '', + baseUrl: '' + } + } + }) + + expect(preserved.provider?.apiKey).toBe('top-level-secret') + expect(preserved.provider?.providers?.find((item) => item.id === 'deepseek')?.apiKey) + .toBe('deepseek-secret') + expect(preserved.provider?.providers?.find((item) => item.id === 'opencode-go')?.apiKey) + .toBe('opencode-secret') + expect(preserved.agents?.kun?.apiKey).toBe('kun-runtime-secret') + }) + + it('keeps newly provided non-empty secrets from the patch', () => { + const prev = settingsWithSecrets() + const preserved = preserveRedactedProviderCredentials(prev, { + provider: { + providers: prev.provider.providers.map((provider) => + provider.id === 'opencode-go' + ? { ...provider, apiKey: 'replacement-secret' } + : { ...provider, apiKey: '' } + ) + } + }) + + expect(preserved.provider?.providers?.find((item) => item.id === 'opencode-go')?.apiKey) + .toBe('replacement-secret') + expect(preserved.provider?.providers?.find((item) => item.id === 'deepseek')?.apiKey) + .toBe('deepseek-secret') + }) + + it('does not invent secrets for brand-new providers', () => { + const prev = settingsWithSecrets() + const preserved = preserveRedactedProviderCredentials(prev, { + provider: { + providers: [ + ...prev.provider.providers.map((provider) => ({ ...provider, apiKey: '' })), + { + id: 'custom-new', + name: 'Custom', + apiKey: '', + baseUrl: 'https://example.test/v1', + endpointFormat: 'chat_completions', + models: [], + modelProfiles: {} + } + ] + } + }) + + expect(preserved.provider?.providers?.find((item) => item.id === 'custom-new')?.apiKey) + .toBe('') + }) +}) diff --git a/src/main/settings-credential-redaction.ts b/src/main/settings-credential-redaction.ts new file mode 100644 index 000000000..6854b45c6 --- /dev/null +++ b/src/main/settings-credential-redaction.ts @@ -0,0 +1,89 @@ +import { + getKunRuntimeSettings, + type AppSettingsPatch, + type AppSettingsV1 +} from '../shared/app-settings' + +/** + * Renderer settings projections intentionally redact provider secrets to `''` + * (`settings:get` / shared-connection projection). Those empty strings must not + * be treated as "user cleared the API key" during `settings:set`, or Main's + * legacy credential migration will `forgetSources` and wipe OAuth/API bindings. + * + * Intentional disconnects go through the protected Registry credential DELETE + * path and do not rely on redacted empty apiKey patches. + * + * Read `prev.provider` directly (not via normalize helpers) so per-provider + * hydrated secrets are not collapsed onto the legacy top-level apiKey field. + */ +export function preserveRedactedProviderCredentials( + prev: AppSettingsV1, + partial: AppSettingsPatch +): AppSettingsPatch { + let next = partial + const previousProviders = Array.isArray(prev.provider?.providers) ? prev.provider.providers : [] + const previousTopLevelApiKey = + typeof prev.provider?.apiKey === 'string' ? prev.provider.apiKey : '' + const previousById = new Map( + previousProviders.map((provider) => [provider.id, provider]) + ) + + if (Array.isArray(partial.provider?.providers)) { + const providers = partial.provider.providers.map((provider) => { + if (!provider || typeof provider.id !== 'string') return provider + const previous = previousById.get(provider.id) + if (!previous?.apiKey.trim()) return provider + if (typeof provider.apiKey !== 'string') return provider + if (provider.apiKey.trim()) return provider + return { ...provider, apiKey: previous.apiKey } + }) + const topLevelApiKey = + typeof partial.provider.apiKey === 'string' && + !partial.provider.apiKey.trim() && + previousTopLevelApiKey.trim() + ? previousTopLevelApiKey + : partial.provider.apiKey + next = { + ...next, + provider: { + ...partial.provider, + ...(topLevelApiKey !== undefined ? { apiKey: topLevelApiKey } : {}), + providers + } + } + } else if ( + typeof partial.provider?.apiKey === 'string' && + !partial.provider.apiKey.trim() && + previousTopLevelApiKey.trim() + ) { + next = { + ...next, + provider: { + ...partial.provider, + apiKey: previousTopLevelApiKey + } + } + } + + const incomingKun = next.agents?.kun + const previousKunApiKey = getKunRuntimeSettings(prev).apiKey + if ( + incomingKun && + typeof incomingKun.apiKey === 'string' && + !incomingKun.apiKey.trim() && + previousKunApiKey.trim() + ) { + next = { + ...next, + agents: { + ...next.agents, + kun: { + ...incomingKun, + apiKey: previousKunApiKey + } + } + } + } + + return next +} diff --git a/src/main/windows-installer-migration.test.ts b/src/main/windows-installer-migration.test.ts index 3a778ae6a..ef10ce98d 100644 --- a/src/main/windows-installer-migration.test.ts +++ b/src/main/windows-installer-migration.test.ts @@ -27,7 +27,7 @@ function makeTempRoot(): string { } function runHelper(input: { - action: 'ResolvePath' | 'ResolveSource' | 'ResolveUpdateScope' | 'ResolveUninstaller' | 'Recover' | 'Prepare' | 'FallbackCleanup' | 'Restore' | 'ValidatePayload' + action: 'ResolvePath' | 'ResolveSource' | 'ResolveUpdateScope' | 'ResolveUninstaller' | 'Recover' | 'Prepare' | 'FallbackCleanup' | 'Restore' | 'ValidatePayload' | 'CleanupInPlaceLeftovers' source?: string secondary?: string currentUserSource?: string @@ -45,6 +45,7 @@ function runHelper(input: { userProfile?: string primarySourceStale?: boolean secondarySourceStale?: boolean + inPlaceUpdate?: boolean installMode?: 'CurrentUser' | 'all' appGuid?: string canonicalLeaf?: string @@ -86,6 +87,7 @@ function runHelper(input: { KUN_INSTALLER_UNINSTALL_STRING: input.uninstallCommand ?? '', KUN_INSTALLER_PRIMARY_SOURCE_STALE: input.primarySourceStale ? '1' : '0', KUN_INSTALLER_SECONDARY_SOURCE_STALE: input.secondarySourceStale ? '1' : '0', + KUN_INSTALLER_IN_PLACE_UPDATE: input.inPlaceUpdate ? '1' : '0', KUN_INSTALLER_INSTALL_MODE: input.installMode ?? 'CurrentUser', KUN_INSTALLER_APP_GUID: input.appGuid ?? 'test-kun-app-guid', KUN_INSTALLER_CANONICAL_LEAF: input.canonicalLeaf ?? 'Kun', @@ -174,6 +176,25 @@ describe('Windows installer migration ACL contract', () => { expect(script).toContain('$process.ExitCode -ne $accessViolationExitCode') expect(script).toContain('retrying once after 2 seconds') }) + + it('keeps same-directory automatic updates from pre-deleting the application payload', () => { + const installerScript = readFileSync(join(process.cwd(), 'build/installer.nsh'), 'utf8') + const migrationScript = readFileSync(helperPath, 'utf8') + + expect(installerScript).toContain('Function KunMarkInPlaceAutomaticUpdate') + expect(installerScript).toContain('${if} $KunInstallerInPlaceUpdate == 1') + expect(installerScript).toContain('skipping pre-install removal of $KunInstallerPrimarySourceDir') + expect(installerScript).toContain( + 'suppressed the selected-scope uninstaller until the new payload is installed' + ) + expect(installerScript.indexOf('!insertmacro kunRunMigrationHelper ValidatePayload')).toBeLessThan( + installerScript.indexOf('!insertmacro kunRunMigrationHelper CleanupInPlaceLeftovers') + ) + expect(migrationScript).toContain('function Invoke-CleanupInPlaceLeftovers') + expect(migrationScript).toContain('function Test-RetainedInPlaceKnownEntry') + expect(smokePath.length).toBeGreaterThan(0) + expect(readFileSync(smokePath, 'utf8')).toContain('in-app all-users automatic update scope') + }) }) windowsOnly('Windows installer migration helper', () => { @@ -187,6 +208,83 @@ windowsOnly('Windows installer migration helper', () => { expect(result.status, processError(result)).toBe(0) }) + it('removes only obsolete known identity files after a validated in-place update', () => { + const target = join(makeTempRoot(), 'Kun') + mkdirSync(target, { recursive: true }) + writePackagedInstallPayload(target) + writeFileSync(join(target, 'DeepSeek GUI.exe'), 'legacy identity') + writeFileSync(join(target, 'Uninstall DeepSeek GUI.exe'), 'legacy uninstaller') + writeFileSync(join(target, 'Uninstall Kun.exe'), 'current uninstaller') + writeFileSync(join(target, 'ffmpeg.dll'), 'runtime') + writeFileSync(join(target, 'notes.txt'), 'user file') + + const result = runHelper({ + action: 'CleanupInPlaceLeftovers', + source: target, + target, + inPlaceUpdate: true + }) + + expect(result.status, processError(result)).toBe(0) + expect(existsSync(join(target, 'Kun.exe'))).toBe(true) + expect(existsSync(join(target, 'Uninstall Kun.exe'))).toBe(true) + expect(existsSync(join(target, 'ffmpeg.dll'))).toBe(true) + expect(existsSync(join(target, 'resources', 'app.asar'))).toBe(true) + expect(existsSync(join(target, 'DeepSeek GUI.exe'))).toBe(false) + expect(existsSync(join(target, 'Uninstall DeepSeek GUI.exe'))).toBe(false) + expect(readFileSync(join(target, 'notes.txt'), 'utf8')).toBe('user file') + }) + + it('does not clean in-place leftovers unless the in-place update marker is set', () => { + const target = join(makeTempRoot(), 'Kun') + mkdirSync(target, { recursive: true }) + writePackagedInstallPayload(target) + writeFileSync(join(target, 'DeepSeek GUI.exe'), 'legacy identity') + + const result = runHelper({ + action: 'CleanupInPlaceLeftovers', + source: target, + target, + inPlaceUpdate: false + }) + + expect(result.status, processError(result)).toBe(0) + expect(existsSync(join(target, 'DeepSeek GUI.exe'))).toBe(true) + }) + + it('refuses in-place leftover cleanup when the validated payload is incomplete', () => { + const target = join(makeTempRoot(), 'Kun') + mkdirSync(target, { recursive: true }) + writePackagedInstallPayload(target) + rmSync(join(target, 'Kun.exe')) + writeFileSync(join(target, 'DeepSeek GUI.exe'), 'legacy identity') + + const result = runHelper({ + action: 'CleanupInPlaceLeftovers', + source: target, + target, + inPlaceUpdate: true + }) + + expect(result.status).not.toBe(0) + expect(processError(result)).toContain('payload is missing') + expect(existsSync(join(target, 'DeepSeek GUI.exe'))).toBe(true) + }) + + it('keeps known application files after prepare for same-directory updates', () => { + const root = makeTempRoot() + const source = join(root, 'Kun') + const journal = join(root, 'recovery', 'journal.json') + mkdirSync(join(source, 'resources'), { recursive: true }) + writeFileSync(join(source, 'Kun.exe'), 'app') + writeFileSync(join(source, 'notes.txt'), 'keep me') + + const prepared = runHelper({ action: 'Prepare', source, target: source, journal }) + expect(prepared.status, processError(prepared)).toBe(0) + expect(existsSync(join(source, 'Kun.exe'))).toBe(true) + expect(existsSync(join(source, 'notes.txt'))).toBe(false) + }) + it.each([ ['application executable', (target: string) => join(target, 'Kun.exe')], ['resources\\app.asar', (target: string) => join(target, 'resources', 'app.asar')], diff --git a/src/preload/index.ts b/src/preload/index.ts index 9c1413796..b8b72e158 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -129,6 +129,8 @@ const api = { respondRendererRequest: (response) => ipcRenderer.invoke('data-migration:renderer-response', response) }, getSettings: () => ipcRenderer.invoke('settings:get'), + revealModelProviderCredential: (providerId) => + ipcRenderer.invoke('model-provider:credential:reveal', { providerId }), resetUnreadableCredentials: () => ipcRenderer.invoke('credentials:reset-unreadable'), cliInstallStatus: () => ipcRenderer.invoke('cli-install:status'), cliInstallAction: (action) => ipcRenderer.invoke('cli-install:action', action), diff --git a/src/renderer/src/agent/agent-tool-provenance.test.ts b/src/renderer/src/agent/agent-tool-provenance.test.ts index 2a0c50446..bd4417710 100644 --- a/src/renderer/src/agent/agent-tool-provenance.test.ts +++ b/src/renderer/src/agent/agent-tool-provenance.test.ts @@ -41,6 +41,12 @@ describe('Agent Perspective tool provenance', () => { expect(resolveToolProvenance('design_svg_edit', undefined)).toMatchObject({ source: 'kun', category: 'kun-gui', inferred: true }) + expect(resolveToolProvenance('browser_use', undefined)).toMatchObject({ + source: 'kun', category: 'kun-gui', inferred: true + }) + expect(resolveToolProvenance('explore_agent', undefined)).toMatchObject({ + source: 'kun', category: 'kun-runtime', inferred: true + }) expect(resolveToolProvenance('mcp_filesystem_read', undefined)).toMatchObject({ source: 'mcp', category: 'mcp-server', inferred: true }) diff --git a/src/renderer/src/agent/agent-tool-provenance.ts b/src/renderer/src/agent/agent-tool-provenance.ts index 673df6229..32e0b980e 100644 --- a/src/renderer/src/agent/agent-tool-provenance.ts +++ b/src/renderer/src/agent/agent-tool-provenance.ts @@ -54,11 +54,11 @@ const LEGACY_KUN_CORE_TOOLS = new Set([ 'request_user_input', 'create_plan', 'read_artifact', 'task_graph' ]) const LEGACY_KUN_GUI_TOOLS = new Set([ - 'computer_use', 'get_goal', 'create_goal', 'update_goal', 'todo_list', 'todo_write' + 'computer_use', 'browser_use', 'get_goal', 'create_goal', 'update_goal', 'todo_list', 'todo_write' ]) const LEGACY_KUN_RUNTIME_TOOLS = new Set([ 'web_search', 'web_fetch', 'load_skill', 'memory_create', 'memory_update', - 'memory_delete', 'delegate_task', 'generate_image', 'generate_speech', + 'memory_delete', 'delegate_task', 'explore_agent', 'generate_image', 'generate_speech', 'generate_music', 'generate_video', 'ppt_master_run', 'ppt_master_read_guide', 'ppt_master_confirm_design' ]) diff --git a/src/renderer/src/agent/kun-contract.ts b/src/renderer/src/agent/kun-contract.ts index 0dd192c60..b5f131dc2 100644 --- a/src/renderer/src/agent/kun-contract.ts +++ b/src/renderer/src/agent/kun-contract.ts @@ -59,6 +59,18 @@ export type CoreThreadJson = CoreThreadSummaryJson & { pendingApprovalIds?: string[] } +export type CoreThreadRuntimeStateJson = { + id: string + status: string + updatedAt: string + latestSeq: number + latestTurn: { + id: string + status: string + orchestration: 'direct' | 'graph' + } | null +} + export type CoreAttachmentMetadataJson = { id: string name: string @@ -488,6 +500,7 @@ export type CoreTurnItemJson = { messageSource?: 'background_shell' | 'background_subagent' | 'graph_runtime' toolName?: string callId?: string + cancelRequestedAt?: string toolKind?: 'tool_call' | 'command_execution' | 'file_change' arguments?: Record output?: unknown @@ -608,6 +621,13 @@ export type CoreStartTurnResponseJson = { userMessageItemId?: string } +export type CoreCancelToolCallResponseJson = { + threadId: string + turnId: string + callId: string + status: 'cancellation_requested' | 'already_requested' +} + export type CoreStartReviewResponseJson = CoreStartTurnResponseJson & { reviewItemId?: string } @@ -666,6 +686,18 @@ export type CoreUsageSnapshotJson = { costUsd?: number costCny?: number tokenEconomySavingsTokens?: number + /** Time-to-first-token of this single model request (ms). */ + requestTtftMs?: number + /** Generation duration of this single model request (ms). */ + requestGenerationMs?: number + /** Average TTFT across model calls of the current turn. */ + turnAvgTtftMs?: number | null + /** Average tokens-per-second across model calls of the current turn. */ + turnAvgTokensPerSecond?: number | null + /** Thread-cumulative average TTFT across all model calls. */ + avgTtftMs?: number | null + /** Thread-cumulative average tokens-per-second across all model calls. */ + avgTokensPerSecond?: number | null } export type CoreRuntimeEventJson = { diff --git a/src/renderer/src/agent/kun-event-normalizer.ts b/src/renderer/src/agent/kun-event-normalizer.ts index 9d25f5f15..1e8c13ad2 100644 --- a/src/renderer/src/agent/kun-event-normalizer.ts +++ b/src/renderer/src/agent/kun-event-normalizer.ts @@ -204,12 +204,17 @@ function normalizeKunRuntimeEventPayload( } }] case 'turn_completed': - case 'turn_aborted': if (event.child) { const tool = deps.childTool(event) return tool ? [{ type: 'tool_updated', payload: tool }] : [] } return [{ type: 'turn_completed' }] + case 'turn_aborted': + if (event.child) { + const tool = deps.childTool(event) + return tool ? [{ type: 'tool_updated', payload: tool }] : [] + } + return [{ type: 'turn_aborted' }] case 'turn_failed': { if (event.child) { const tool = deps.childTool(event) diff --git a/src/renderer/src/agent/kun-mapper.test.ts b/src/renderer/src/agent/kun-mapper.test.ts index f8ca0a9d1..0178ac2c7 100644 --- a/src/renderer/src/agent/kun-mapper.test.ts +++ b/src/renderer/src/agent/kun-mapper.test.ts @@ -207,6 +207,19 @@ describe('runtime projection action normalization', () => { }]) }) + it('keeps turn interruption distinct from successful completion', () => { + expect(runtimeProjectionActionsFromEvent({ + kind: 'turn_completed', + threadId: 'thread_1', + turnId: 'turn_1' + })).toEqual([{ type: 'turn_completed' }]) + expect(runtimeProjectionActionsFromEvent({ + kind: 'turn_aborted', + threadId: 'thread_1', + turnId: 'turn_1' + })).toEqual([{ type: 'turn_aborted' }]) + }) + it('normalizes the same goal event to a stable action transcript', () => { const event: CoreRuntimeEventJson = { kind: 'goal_updated', @@ -2118,6 +2131,78 @@ describe('usage event mapping', () => { turns: 1 }) }) + + it('passes through per-turn and session timing averages', async () => { + let captured: unknown = null + const sink: ThreadEventSink = { + ...makeSink(), + onUsage: (usage) => { + captured = usage + } + } + + await dispatchKunRuntimeEvent( + { + kind: 'usage', + seq: 14, + turnId: 'turn_1', + usage: { + promptTokens: 100, + completionTokens: 50, + totalTokens: 150, + turnAvgTtftMs: 1_000, + turnAvgTokensPerSecond: 40.2, + avgTtftMs: 1_200, + avgTokensPerSecond: 38.5, + turns: 1 + } + }, + sink, + async () => undefined + ) + + expect(captured).toMatchObject({ + turnAvgTtftMs: 1_000, + turnAvgTokensPerSecond: 40.2, + avgTtftMs: 1_200, + avgTokensPerSecond: 38.5, + turnId: 'turn_1' + }) + }) + + it('normalizes missing or invalid timing fields to null', async () => { + let captured: unknown = null + const sink: ThreadEventSink = { + ...makeSink(), + onUsage: (usage) => { + captured = usage + } + } + + await dispatchKunRuntimeEvent( + { + kind: 'usage', + seq: 15, + usage: { + promptTokens: 10, + completionTokens: 2, + totalTokens: 12, + turnAvgTtftMs: Number.NaN, + turns: 1 + } + }, + sink, + async () => undefined + ) + + expect(captured).toMatchObject({ + turnAvgTtftMs: null, + turnAvgTokensPerSecond: null, + avgTtftMs: null, + avgTokensPerSecond: null + }) + expect((captured as { turnId?: string }).turnId).toBeUndefined() + }) }) describe('context snapshot event mapping', () => { diff --git a/src/renderer/src/agent/kun-mapper.ts b/src/renderer/src/agent/kun-mapper.ts index 3f7f31e31..c5bd43e78 100644 --- a/src/renderer/src/agent/kun-mapper.ts +++ b/src/renderer/src/agent/kun-mapper.ts @@ -850,7 +850,8 @@ function toolBlockFromItem(item: CoreTurnItemJson, child?: CoreChildRuntimeMetad sourceItemId: item.id, sourceItemKind: item.kind, ...(item.callId ? { callId: item.callId } : {}), - ...(item.toolName ? { toolName: item.toolName } : {}) + ...(item.toolName ? { toolName: item.toolName } : {}), + ...(item.cancelRequestedAt ? { cancelRequestedAt: item.cancelRequestedAt } : {}) } applyRuntimeDisclosureMeta(meta, item, child) const sources = extractToolSources(item) @@ -1061,7 +1062,7 @@ function normalizeUserInputAnswer(answer: unknown): UserInputAnswer | null { } } -function usageFromCore(usage: CoreUsageSnapshotJson): ThreadUsageSnapshot { +function usageFromCore(usage: CoreUsageSnapshotJson, turnId?: string): ThreadUsageSnapshot { const inputTokens = usage.promptTokens ?? 0 const outputTokens = usage.completionTokens ?? 0 const hasHitTokens = typeof usage.cacheHitTokens === 'number' && Number.isFinite(usage.cacheHitTokens) @@ -1085,10 +1086,20 @@ function usageFromCore(usage: CoreUsageSnapshotJson): ThreadUsageSnapshot { costUsd: usage.costUsd ?? 0, costCny: usage.costCny ?? null, tokenEconomySavingsTokens: usage.tokenEconomySavingsTokens ?? 0, - turns: usage.turns ?? 0 + turns: usage.turns ?? 0, + avgTtftMs: nullableFinite(usage.avgTtftMs), + avgTokensPerSecond: nullableFinite(usage.avgTokensPerSecond), + turnAvgTtftMs: nullableFinite(usage.turnAvgTtftMs), + turnAvgTokensPerSecond: nullableFinite(usage.turnAvgTokensPerSecond), + ...(turnId ? { turnId } : {}) } } +/** Pass through a nullable metric, normalizing non-finite values to null. */ +function nullableFinite(value: number | null | undefined): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + function contextSnapshotFromCore(event: CoreRuntimeEventJson): RequestContextSnapshot | null { const threadId = event.threadId?.trim() const model = event.model?.trim() @@ -1815,7 +1826,7 @@ const kunEventNormalizerDeps: KunEventNormalizerDeps = { }), contextSnapshot: contextSnapshotFromCore, delegatedRuntime: delegatedRuntimeFromCore, - usage: (event) => event.usage ? usageFromCore(event.usage) : null, + usage: (event) => event.usage ? usageFromCore(event.usage, event.turnId) : null, runtimeError: runtimeErrorFromEvent, errorFromRuntime: errorForRuntimeEvent } @@ -1853,7 +1864,8 @@ async function applyRuntimeProjectionAction( case 'context_snapshot_received': sink.onContextSnapshot?.(action.payload); return case 'delegated_runtime_received': sink.onDelegatedRuntimeState?.(action.payload); return case 'usage_received': sink.onUsage?.(action.payload); return - case 'turn_completed': sink.onTurnComplete(); return + case 'turn_completed': sink.onTurnComplete('completed'); return + case 'turn_aborted': sink.onTurnComplete('aborted'); return case 'turn_failed': sink.onError(action.error, action.options); return } } diff --git a/src/renderer/src/agent/kun-runtime.test.ts b/src/renderer/src/agent/kun-runtime.test.ts index ea92e573b..ff9bc78c1 100644 --- a/src/renderer/src/agent/kun-runtime.test.ts +++ b/src/renderer/src/agent/kun-runtime.test.ts @@ -357,6 +357,31 @@ describe('KunRuntimeProvider', () => { expect(detail.latestTurnOrchestration).toBe(expected) }) + it('loads lightweight thread state without requesting full detail', async () => { + const runtimeRequest = vi.fn(async (path: string) => ({ + ok: true, + status: 200, + body: JSON.stringify({ + id: 'thr_state', + status: 'running', + updatedAt: '2026-08-07T00:00:00.000Z', + latestSeq: 91, + latestTurn: { id: 'turn_state', status: 'running', orchestration: 'direct' } + }) + })) + installDsGui({ runtimeRequest }) + + await expect(new KunRuntimeProvider().getThreadState('thr_state')).resolves.toEqual({ + status: 'running', + updatedAt: '2026-08-07T00:00:00.000Z', + latestSeq: 91, + latestTurnId: 'turn_state', + latestTurnStatus: 'running', + latestTurnOrchestration: 'direct' + }) + expect(runtimeRequest).toHaveBeenCalledWith('/v1/threads/thr_state/state', 'GET') + }) + it('rehydrates persisted partial assistant output for a running turn', async () => { installDsGui({ runtimeRequest: vi.fn(async () => ({ diff --git a/src/renderer/src/agent/kun-runtime.ts b/src/renderer/src/agent/kun-runtime.ts index 837f7396e..33ec09b43 100644 --- a/src/renderer/src/agent/kun-runtime.ts +++ b/src/renderer/src/agent/kun-runtime.ts @@ -27,7 +27,9 @@ import { kunThreadRewindPath, kunThreadTodosPath, kunThreadInterruptPath, + kunThreadToolCancelPath, kunThreadPath, + kunThreadStatePath, kunThreadSteerPath, kunThreadTurnsPath, kunAttachmentContentPath, @@ -65,9 +67,11 @@ import type { CoreStartReviewResponseJson, CoreClearThreadGoalResponseJson, CoreClearThreadTodosResponseJson, + CoreCancelToolCallResponseJson, CoreStartTurnResponseJson, CoreThreadGoalResponseJson, CoreThreadJson, + CoreThreadRuntimeStateJson, CoreThreadSummaryJson, CoreThreadTodosResponseJson } from './kun-contract' @@ -303,6 +307,7 @@ export class KunRuntimeProvider implements AgentProvider { latestSeq: number threadStatus?: string latestTurnId?: string + latestTurnStatus?: string latestTurnOrchestration?: 'direct' | 'graph' latestUserMessageId?: string turnDurationByUserId?: Record @@ -311,6 +316,7 @@ export class KunRuntimeProvider implements AgentProvider { parentThreadId?: string goal?: NormalizedThread['goal'] todos?: NormalizedThread['todos'] + payloadBytes?: number }> { const response = await rendererRuntimeClient.runtimeRequest(kunThreadPath(threadId), 'GET') if (!response.ok) { @@ -377,6 +383,7 @@ export class KunRuntimeProvider implements AgentProvider { latestSeq: thread.latestSeq ?? 0, threadStatus: thread.status ?? latestTurn?.status, latestTurnId: latestTurn?.id, + latestTurnStatus: latestTurn?.status, latestTurnOrchestration: latestTurn ? latestTurn.orchestration === 'graph' ? 'graph' : 'direct' : undefined, @@ -385,7 +392,38 @@ export class KunRuntimeProvider implements AgentProvider { ...(thread.parentThreadId ? { parentThreadId: thread.parentThreadId } : {}), ...(typeof thread.model === 'string' && thread.model.trim() ? { model: thread.model.trim() } : {}), goal: thread.goal ? goalFromCore(thread.goal) : null, - todos: thread.todos ? todosFromCore(thread.todos) : null + todos: thread.todos ? todosFromCore(thread.todos) : null, + payloadBytes: response.body.length + } + } + + async getThreadState(threadId: string): Promise<{ + status: string + updatedAt: string + latestSeq: number + latestTurnId?: string + latestTurnStatus?: string + latestTurnOrchestration?: 'direct' | 'graph' + }> { + const response = await rendererRuntimeClient.runtimeRequest(kunThreadStatePath(threadId), 'GET') + if (!response.ok) { + throw runtimeErrorToError(readRuntimeError(response.body, 'failed to load thread state')) + } + const state = readRuntimeJson( + response.body, + 'runtime returned an invalid thread state response' + ) + return { + status: state.status, + updatedAt: state.updatedAt, + latestSeq: state.latestSeq, + ...(state.latestTurn + ? { + latestTurnId: state.latestTurn.id, + latestTurnStatus: state.latestTurn.status, + latestTurnOrchestration: state.latestTurn.orchestration + } + : {}) } } @@ -586,6 +624,24 @@ export class KunRuntimeProvider implements AgentProvider { } } + async cancelToolCall( + threadId: string, + turnId: string, + callId: string + ): Promise { + const response = await rendererRuntimeClient.runtimeRequest( + kunThreadToolCancelPath(threadId, turnId, callId), + 'POST' + ) + if (!response.ok) { + throw runtimeErrorToError(readRuntimeError(response.body, 'failed to cancel tool call')) + } + return readRuntimeJson( + response.body, + 'runtime returned an invalid tool cancellation response' + ) + } + async renameThread(threadId: string, title: string, auto?: boolean): Promise { const response = await rendererRuntimeClient.runtimeRequest( kunThreadPath(threadId), diff --git a/src/renderer/src/agent/runtime-projection-actions.ts b/src/renderer/src/agent/runtime-projection-actions.ts index 046be84ad..90bbfd733 100644 --- a/src/renderer/src/agent/runtime-projection-actions.ts +++ b/src/renderer/src/agent/runtime-projection-actions.ts @@ -67,6 +67,7 @@ type RuntimeProjectionActionPayload = } } | { type: 'turn_completed' } + | { type: 'turn_aborted' } | { type: 'turn_failed'; error: Error; options?: ThreadErrorOptions } /** diff --git a/src/renderer/src/agent/types.ts b/src/renderer/src/agent/types.ts index 84abd93be..6462e6aba 100644 --- a/src/renderer/src/agent/types.ts +++ b/src/renderer/src/agent/types.ts @@ -604,6 +604,16 @@ export type ThreadUsageSnapshot = { costCny: number | null tokenEconomySavingsTokens: number turns: number + /** Thread-cumulative average time-to-first-token across model calls (ms). */ + avgTtftMs: number | null + /** Thread-cumulative average tokens-per-second across model calls. */ + avgTokensPerSecond: number | null + /** Average TTFT across model calls of the current turn (null = no data). */ + turnAvgTtftMs: number | null + /** Average tokens-per-second across model calls of the current turn. */ + turnAvgTokensPerSecond: number | null + /** Turn this snapshot was emitted for (for per-turn metric attribution). */ + turnId?: string } export type RequestContextSnapshot = { @@ -673,7 +683,7 @@ export type ThreadEventSink = { onTodos?(ev: { threadId: string; todos: ThreadTodoList | null; cleared?: boolean; createdAt?: string }): void /** Thread metadata changed out-of-band (e.g. the backend LLM titler upgraded the title). */ onThreadUpdated?(ev: { threadId: string; title?: string; titleAuto?: boolean; status?: string }): void - onTurnComplete(): void + onTurnComplete(status?: 'completed' | 'aborted'): void onError(err: Error, options?: ThreadErrorOptions): void /** Optional: cumulative usage update for the thread. */ onUsage?(usage: ThreadUsageSnapshot): void @@ -706,6 +716,7 @@ export interface AgentProvider { latestSeq: number threadStatus?: string latestTurnId?: string + latestTurnStatus?: string latestTurnOrchestration?: 'direct' | 'graph' latestUserMessageId?: string turnDurationByUserId?: Record @@ -715,6 +726,16 @@ export interface AgentProvider { model?: string goal?: ThreadGoal | null todos?: ThreadTodoList | null + /** Original detail response size, used only to bound renderer snapshots. */ + payloadBytes?: number + }> + getThreadState(threadId: string): Promise<{ + status: string + updatedAt: string + latestSeq: number + latestTurnId?: string + latestTurnStatus?: string + latestTurnOrchestration?: 'direct' | 'graph' }> sendUserMessage( threadId: string, @@ -804,6 +825,11 @@ export interface AgentProvider { options?: { displayText?: string } ): Promise interruptTurn(threadId: string, turnId: string, options?: { discard?: boolean }): Promise + cancelToolCall?( + threadId: string, + turnId: string, + callId: string + ): Promise<{ status: 'cancellation_requested' | 'already_requested' }> /** * Rename a thread. `auto` marks the title as provisional/auto (true, e.g. the * client first-message heuristic — the backend LLM titler may upgrade it) or diff --git a/src/renderer/src/components/Workbench.tsx b/src/renderer/src/components/Workbench.tsx index 2c1df6a44..a9c8dc34c 100644 --- a/src/renderer/src/components/Workbench.tsx +++ b/src/renderer/src/components/Workbench.tsx @@ -1344,6 +1344,11 @@ export function Workbench(): ReactElement { devPreviewOpened: rightPanelMode === BUILTIN_RIGHT_PANEL_IDS.browser, returnParentTitle: threads.find((thread) => thread.id === activeThreadParentId)?.title?.trim() ?? '', showReturnBar: activeThreadRelation === 'side' && Boolean(activeThreadParentId), + returnBarVariant: ( + threads.find((thread) => thread.id === activeThreadId)?.agentId === 'explore' + ? 'explore' + : 'subagent' + ) as 'explore' | 'subagent', graphChildContext, composerProps: chatComposerProps, conversationDropWorkspaceRoot: activeSkillWorkspace, diff --git a/src/renderer/src/components/chat/ExplorePeekPopover.tsx b/src/renderer/src/components/chat/ExplorePeekPopover.tsx new file mode 100644 index 000000000..a5ed2b81e --- /dev/null +++ b/src/renderer/src/components/chat/ExplorePeekPopover.tsx @@ -0,0 +1,235 @@ +import { + useEffect, + useRef, + useState, + type CSSProperties, + type ReactElement +} from 'react' +import { createPortal } from 'react-dom' +import { ExternalLink, X } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { getProvider } from '../../agent/registry' +import type { ChatBlock, RuntimeChildActivity } from '../../agent/types' +import { threadSnapshotLooksRunning } from '../../store/chat-store-runtime-helpers' +import { + calculateComposerPopoverPlacement, + currentComposerBodyZoom, + type ComposerPopoverPlacement +} from './floating-composer-popover-placement' +import { ExplorePeekBody } from './explore-peek-body' +import { + formatChildActivityLabel, + summarizeExplorePeekBlocks, + type ExplorePeekStep +} from './explore-peek-summary' + +export { ExplorePeekBody } from './explore-peek-body' + +const PEEK_POPOVER_WIDTH = 420 +const PEEK_POPOVER_MAX_HEIGHT = 360 +const PEEK_POPOVER_ESTIMATED_HEIGHT = 280 +const PEEK_POLL_MS = 1_200 + +export type ExplorePeekPopoverProps = { + open: boolean + anchorEl: HTMLElement | null + childId: string + title: string + elapsedLabel: string + statusLabel: string + activity?: RuntimeChildActivity + summary?: string + onClose: () => void + onOpenChildThread?: (threadId: string) => void +} + +export function ExplorePeekPopover({ + open, + anchorEl, + childId, + title, + elapsedLabel, + statusLabel, + activity, + summary, + onClose, + onOpenChildThread +}: ExplorePeekPopoverProps): ReactElement | null { + const { t } = useTranslation('common') + const popoverRef = useRef(null) + const [placement, setPlacement] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [steps, setSteps] = useState([]) + const [reasoningPreview, setReasoningPreview] = useState() + const [assistantPreview, setAssistantPreview] = useState() + + useEffect(() => { + if (!open || !childId) { + setSteps([]) + setReasoningPreview(undefined) + setAssistantPreview(undefined) + setError(null) + setLoading(false) + return + } + let cancelled = false + let pollTimer: number | null = null + setLoading(true) + setError(null) + + const load = async (): Promise => { + try { + const detail = await getProvider().getThreadDetail(childId) + if (cancelled) return + const peek = summarizeExplorePeekBlocks(detail.blocks as ChatBlock[]) + setSteps(peek.steps) + setReasoningPreview(peek.reasoningPreview) + setAssistantPreview(peek.assistantPreview) + setError(null) + if (threadSnapshotLooksRunning(detail.blocks, detail.threadStatus)) { + pollTimer = window.setTimeout(() => { + void load() + }, PEEK_POLL_MS) + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : String(err)) + } + } finally { + if (!cancelled) setLoading(false) + } + } + + void load() + return () => { + cancelled = true + if (pollTimer !== null) window.clearTimeout(pollTimer) + } + }, [open, childId]) + + useEffect(() => { + if (!open || !anchorEl || typeof window === 'undefined') { + setPlacement(null) + return + } + const updatePlacement = (): void => { + setPlacement(calculateComposerPopoverPlacement({ + anchorRect: anchorEl.getBoundingClientRect(), + popoverHeight: popoverRef.current?.offsetHeight ?? PEEK_POPOVER_ESTIMATED_HEIGHT, + viewportHeight: window.innerHeight, + viewportWidth: window.innerWidth, + coordinateScale: currentComposerBodyZoom(), + preferredWidth: PEEK_POPOVER_WIDTH, + maximumHeight: PEEK_POPOVER_MAX_HEIGHT + })) + } + updatePlacement() + const frame = window.requestAnimationFrame(updatePlacement) + window.addEventListener('resize', updatePlacement) + window.addEventListener('scroll', updatePlacement, true) + return () => { + window.cancelAnimationFrame(frame) + window.removeEventListener('resize', updatePlacement) + window.removeEventListener('scroll', updatePlacement, true) + } + }, [open, anchorEl, steps.length, loading, error, reasoningPreview, assistantPreview]) + + useEffect(() => { + if (!open || typeof window === 'undefined') return + const onPointerDown = (event: PointerEvent): void => { + const target = event.target + if (!(target instanceof Node)) return + if (anchorEl?.contains(target) || popoverRef.current?.contains(target)) return + onClose() + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') onClose() + } + window.addEventListener('pointerdown', onPointerDown) + window.addEventListener('keydown', onKeyDown) + return () => { + window.removeEventListener('pointerdown', onPointerDown) + window.removeEventListener('keydown', onKeyDown) + } + }, [open, anchorEl, onClose]) + + if (!open || typeof document === 'undefined') return null + + const activityLabel = formatChildActivityLabel(activity) + const style: CSSProperties | undefined = placement + ? { + position: 'fixed', + left: placement.left, + top: placement.top, + width: placement.width, + maxHeight: placement.maxHeight, + zIndex: 80 + } + : { + position: 'fixed', + left: -9999, + top: -9999, + width: PEEK_POPOVER_WIDTH, + maxHeight: PEEK_POPOVER_MAX_HEIGHT, + zIndex: 80, + visibility: 'hidden' + } + + return createPortal( +
+
+
+
+ {title} +
+
+ {statusLabel} + {elapsedLabel} +
+ {activityLabel ? ( +
+ {activityLabel} +
+ ) : null} +
+ {childId ? ( + + ) : null} + +
+ + +
, + document.body + ) +} diff --git a/src/renderer/src/components/chat/FloatingComposer.test.ts b/src/renderer/src/components/chat/FloatingComposer.test.ts index d7557ae06..9f47cbfd7 100644 --- a/src/renderer/src/components/chat/FloatingComposer.test.ts +++ b/src/renderer/src/components/chat/FloatingComposer.test.ts @@ -1889,6 +1889,30 @@ describe('FloatingComposer capability controls', () => { language: '', timeoutMs: 60_000 })).toBe(false) + + expect(shouldShowVoiceDictation({ + enabled: true, + providerId: 'grok-subscription', + protocol: 'xai-stt', + baseUrl: 'https://api.x.ai/v1', + apiKey: '', + model: 'grok-transcribe', + localWhisperDownloadSource: 'huggingface', + language: '', + timeoutMs: 60_000 + })).toBe(false) + + expect(shouldShowVoiceDictation({ + enabled: true, + providerId: 'grok-subscription', + protocol: 'xai-stt', + baseUrl: 'https://api.x.ai/v1', + apiKey: '', + model: 'grok-transcribe', + localWhisperDownloadSource: 'huggingface', + language: '', + timeoutMs: 60_000 + }, true)).toBe(true) }) it('surfaces user-input requests in Chat, Design, and the compact Write composer', () => { diff --git a/src/renderer/src/components/chat/FloatingComposer.tsx b/src/renderer/src/components/chat/FloatingComposer.tsx index d274ea9f0..2fedd895e 100644 --- a/src/renderer/src/components/chat/FloatingComposer.tsx +++ b/src/renderer/src/components/chat/FloatingComposer.tsx @@ -64,6 +64,8 @@ import { formatCost, formatPercent, cumulativeCacheHitRate, + formatTtftSeconds, + formatTps, useThreadUsageState } from '../../hooks/use-thread-usage' import { FloatingComposerContextCapacity } from './FloatingComposerContextCapacity' @@ -135,9 +137,10 @@ export type { ComposerFileReference } from '../../lib/composer-file-references' export type { ComposerExecutionSettings } from './FloatingComposerExecutionPicker' export function shouldShowVoiceDictation( - speechToText: KunSpeechToTextSettingsV1 | null | undefined + speechToText: KunSpeechToTextSettingsV1 | null | undefined, + credentialReady = false ): boolean { - return speechToText != null && isSpeechToTextConfigured(speechToText) + return speechToText != null && isSpeechToTextConfigured(speechToText, { credentialReady }) } export function returnQueuedMessageToComposer( @@ -401,6 +404,7 @@ export function FloatingComposer({ const route = useChatStore((s) => s.route) const workspaceRoot = useChatStore((s) => s.workspaceRoot) const storeActiveThreadId = useChatStore((s) => s.activeThreadId) + const threadLoadingId = useChatStore((s) => s.threadLoadingId) const activeThreadId = activeThreadIdOverride === undefined ? storeActiveThreadId : activeThreadIdOverride @@ -446,7 +450,8 @@ export function FloatingComposer({ onResolveUserInput ?? resolveUserInput ) const fileInputRef = useRef(null) - const speechToTextSettings = useSpeechToTextSettings() + const { speechToText: speechToTextSettings, credentialReady: speechCredentialReady } = + useSpeechToTextSettings() const promptOptimizationSettings = usePromptOptimizationSettings() const dictationInputRef = useRef(input) useEffect(() => { @@ -465,7 +470,7 @@ export function FloatingComposer({ } } }) - const showVoiceDictation = shouldShowVoiceDictation(speechToTextSettings) + const showVoiceDictation = shouldShowVoiceDictation(speechToTextSettings, speechCredentialReady) const activeClawChannel = useMemo( () => clawChannels.find((channel) => channel.id === activeClawChannelId) ?? null, [activeClawChannelId, clawChannels] @@ -491,6 +496,15 @@ export function FloatingComposer({ `${activeThread?.updatedAt ?? ''}:${busy ? 'busy' : 'idle'}:${usageRefreshKey}` ) const threadUsage = threadUsageState.usage + /** + * Live session-average TTFT/TPS from the latest usage SSE event of the + * active thread. The REST summary above does not carry these timing fields. + */ + const liveThreadUsage = useChatStore((s) => + s.lastTurnUsage && s.lastTurnUsage.threadId === s.activeThreadId + ? s.lastTurnUsage.snapshot + : null + ) const effectiveWorkspaceRoot = normalizeWorkspaceRoot(activeThreadWorkspace || workspaceRootOverride || workspaceRoot) const clawAgentName = activeClawChannel?.agentProfile.name.trim() @@ -504,8 +518,9 @@ export function FloatingComposer({ activeClawChannel?.remoteSession?.chatId?.trim() ) - const canEditComposer = !disabled && (route === 'claw' ? clawHasInboundConversation : true) - const canCompose = !disabled && runtimeReady && ( + const hydratingActiveThread = activeThreadId != null && threadLoadingId === activeThreadId + const canEditComposer = !disabled && !hydratingActiveThread && (route === 'claw' ? clawHasInboundConversation : true) + const canCompose = !disabled && !hydratingActiveThread && runtimeReady && ( route === 'claw' ? clawHasInboundConversation : (hasActiveThread || !!effectiveWorkspaceRoot) @@ -1968,7 +1983,6 @@ export function FloatingComposer({ { tokens: formatCompactNumber(threadUsage.totalTokens), cost: formatCost(threadUsage.costUsd, i18n.language, threadUsage.costCny), - saved: formatCompactNumber(threadUsage.tokenEconomySavingsTokens), cache: formatPercent(threadUsage.cacheHitRate), latestCache: formatPercent(threadUsage.lastTurnCacheHitRate), cached: formatCompactNumber(threadUsage.cachedTokens), @@ -1995,21 +2009,6 @@ export function FloatingComposer({ cost: formatCost(threadUsage.costUsd, i18n.language, threadUsage.costCny) })} - {threadUsage.tokenEconomySavingsTokens > 0 ? ( - <> - · - - {t('sessionUsageContextSavings', { - tokens: formatCompactNumber(threadUsage.tokenEconomySavingsTokens) - })} - - - ) : null} {threadUsage.turns > 1 ? ( <> · @@ -2024,6 +2023,21 @@ export function FloatingComposer({ {t('sessionUsageTurns', { turns: threadUsage.turns })} + {liveThreadUsage && + (liveThreadUsage.avgTtftMs != null || liveThreadUsage.avgTokensPerSecond != null) ? ( + <> + · + + {t('sessionUsageAvgMetrics', { + ttft: formatTtftSeconds(liveThreadUsage.avgTtftMs) ?? '-', + tps: formatTps(liveThreadUsage.avgTokensPerSecond) ?? '-' + })} + + + ) : null} ) : activeThreadId ? ( diff --git a/src/renderer/src/components/chat/MessageTimeline.tool-summary.test.ts b/src/renderer/src/components/chat/MessageTimeline.tool-summary.test.ts index 8a30b6384..bd9630ab6 100644 --- a/src/renderer/src/components/chat/MessageTimeline.tool-summary.test.ts +++ b/src/renderer/src/components/chat/MessageTimeline.tool-summary.test.ts @@ -16,7 +16,8 @@ import { import { GeneratedFilesPanel, MessageBubble, - generatedMediaScrollAvailability + generatedMediaScrollAvailability, + turnMetricsLabel } from './message-timeline-bubbles' import { describeProcessSection, @@ -45,6 +46,8 @@ const labels: Record = { toolActionBackgroundShellList: 'List background shells', workingToolAction: 'Working {{action}}', thinkingNow: 'Thinking…', + turnMetricsTtft: 'Avg TTFT {{value}}', + turnMetricsTps: 'Avg {{value}} tok/s', groupReadFiles: 'Read {{count}} files', groupReadFile: 'Read 1 file', groupSearched: 'Searched {{count}} times', @@ -196,6 +199,22 @@ describe('MessageTimeline tool summaries', () => { expect(find).toBe('Find *.ts · /tmp/src') }) + it('summarizes explore_agent with its short UI title', () => { + expect( + summarizeToolBlock( + toolBlock({ + summary: 'explore_agent', + meta: { toolName: 'explore_agent' }, + detail: JSON.stringify({ + title: 'Voice transcription flow', + query: 'Trace speech transcription wiring' + }) + }), + t + ) + ).toBe('Explore agent Voice transcription flow') + }) + it('does not repeat a raw summary that matches the generated tool label', () => { expect( summarizeToolBlock( @@ -366,6 +385,102 @@ describe('MessageTimeline tool summaries', () => { } ]) }) + + it('keeps sibling explore_agent calls as independent subagent sections', () => { + const sections = groupProcessSections([ + toolBlock({ + id: 'explore_1', + summary: 'explore packaging', + meta: { + toolName: 'explore_agent', + child: { + parentThreadId: 'thread_parent', + parentTurnId: 'turn_1', + childId: 'child_1', + childProfile: 'explore', + childSeq: 1 + } + } + }), + toolBlock({ + id: 'explore_2', + summary: 'explore workflow', + meta: { + toolName: 'explore_agent', + child: { + parentThreadId: 'thread_parent', + parentTurnId: 'turn_1', + childId: 'child_2', + childProfile: 'explore', + childSeq: 2 + } + } + }), + toolBlock({ + id: 'explore_3', + summary: 'explore runtime', + meta: { + toolName: 'explore_agent', + child: { + parentThreadId: 'thread_parent', + parentTurnId: 'turn_1', + childId: 'child_3', + childProfile: 'explore', + childSeq: 3 + } + } + }) + ]) + + expect(sections.map((section) => ({ + kind: section.kind, + ids: section.blocks.map((block) => block.id) + }))).toEqual([ + { kind: 'subagent', ids: ['explore_1'] }, + { kind: 'subagent', ids: ['explore_2'] }, + { kind: 'subagent', ids: ['explore_3'] } + ]) + }) + + it('still coalesces sibling non-explore delegate_task calls into one swarm section', () => { + const sections = groupProcessSections([ + toolBlock({ + id: 'delegate_1', + summary: 'General Agent 1', + meta: { + toolName: 'delegate_task', + child: { + parentThreadId: 'thread_parent', + parentTurnId: 'turn_1', + childId: 'child_a', + childProfile: 'general', + childSeq: 1 + } + } + }), + toolBlock({ + id: 'delegate_2', + summary: 'General Agent 2', + meta: { + toolName: 'delegate_task', + child: { + parentThreadId: 'thread_parent', + parentTurnId: 'turn_1', + childId: 'child_b', + childProfile: 'general', + childSeq: 2 + } + } + }) + ]) + + expect(sections.map((section) => ({ + kind: section.kind, + ids: section.blocks.map((block) => block.id) + }))).toEqual([ + { kind: 'subagent', ids: ['delegate_1', 'delegate_2'] } + ]) + }) }) describe('MessageTimeline Kun runtime metadata smoke', () => { @@ -409,6 +524,64 @@ describe('MessageTimeline Kun runtime metadata smoke', () => { expect(html).toContain('为什么图片完全没有识别啊') expect(html).not.toContain('Attachments 1') expect(html).not.toContain('ds-media-printer-reveal') + expect(html).toContain('data-user-media-gallery') + expect(html).toContain('data-user-media-count="1"') + expect(html).toContain('max-w-[min(100%,20rem)]') + expect(html).not.toContain('data-user-media-carousel') + expect(html).not.toContain('generatedFileDownload') + }) + + it('keeps two or three user images in a row without carousel controls', () => { + const attachments = [1, 2, 3].map((index) => ({ + id: `att_${index}`, + name: `image-${index}.png`, + mimeType: 'image/png', + previewUrl: `data:image/png;base64,img${index}` + })) + const block: ChatBlock = { + kind: 'user', + id: 'user_multi', + text: '三张图', + meta: { + attachmentIds: attachments.map((item) => item.id), + attachments + } + } + + const html = renderToStaticMarkup(createElement(MessageBubble, { block })) + + expect(html).toContain('data-user-media-count="3"') + expect(html).not.toContain('data-user-media-carousel') + expect(html).not.toContain('generatedFilesPreviousImages') + expect(html).not.toContain('generatedFilesNextImages') + expect(html).toContain('src="data:image/png;base64,img1"') + expect(html).toContain('src="data:image/png;base64,img3"') + }) + + it('enables the user media carousel only when there are more than three images', () => { + const attachments = [1, 2, 3, 4].map((index) => ({ + id: `att_${index}`, + name: `image-${index}.png`, + mimeType: 'image/png', + previewUrl: `data:image/png;base64,img${index}` + })) + const block: ChatBlock = { + kind: 'user', + id: 'user_carousel', + text: '四张图', + meta: { + attachmentIds: attachments.map((item) => item.id), + attachments + } + } + + const html = renderToStaticMarkup(createElement(MessageBubble, { block })) + + expect(html).toContain('data-user-media-gallery') + expect(html).toContain('data-user-media-count="4"') + expect(html).toContain('data-user-media-carousel') + expect(html).toContain('snap-x') + expect(html).toContain('overflow-x-auto') }) it('renders user file references under the sent prompt', () => { @@ -1865,6 +2038,43 @@ describe('MessageTimeline Kun runtime metadata smoke', () => { expect(html).toMatch(/writeExportPng|Export PNG|导出 PNG/) }) + it('renders per-turn average TTFT/TPS next to the timestamp when available', () => { + useChatStore.setState({ + turnTimingMetrics: new Map([ + ['turn_1', { avgTtftMs: 1_000, avgTokensPerSecond: 40.2 }] + ]) + }) + try { + const html = renderToStaticMarkup( + createElement(MessageBubble, { + block: { + kind: 'assistant', + id: 'assistant_1', + turnId: 'turn_1', + text: 'hello' + } + }) + ) + + // zustand v5 serves SSR renders from the INITIAL state, so the + // per-turn map set above is not visible here; verify the wiring + // through the client render path instead. + expect(turnMetricsLabel(t, { avgTtftMs: 1_000, avgTokensPerSecond: 40.2 })) + .toBe('Avg TTFT 1.0s · Avg 40.2 tok/s') + expect(html).not.toContain('tok/s') + } finally { + useChatStore.setState({ turnTimingMetrics: new Map() }) + } + }) + + it('omits segments without timing data from the footer label', () => { + expect(turnMetricsLabel(t, { avgTtftMs: null, avgTokensPerSecond: null })).toBe('') + expect(turnMetricsLabel(t, { avgTtftMs: 800, avgTokensPerSecond: null })) + .toBe('Avg TTFT 0.8s') + expect(turnMetricsLabel(t, { avgTtftMs: null, avgTokensPerSecond: 38.5 })) + .toBe('Avg 38.5 tok/s') + }) + it('renders the workspace rollback action with fork in completed assistant response actions', () => { const blocks: ChatBlock[] = [ { diff --git a/src/renderer/src/components/chat/MessageTimeline.tsx b/src/renderer/src/components/chat/MessageTimeline.tsx index e1cd72649..e767c7033 100644 --- a/src/renderer/src/components/chat/MessageTimeline.tsx +++ b/src/renderer/src/components/chat/MessageTimeline.tsx @@ -1,8 +1,9 @@ import type { ReactElement, RefObject } from 'react' -import { memo, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { ChevronDown, CircleAlert, GitCommitHorizontal, Hash } from 'lucide-react' -import type { ChatBlock, RuntimeConnectionStatus } from '../../agent/types' +import type { ChatBlock, RuntimeChildActivity, RuntimeConnectionStatus, ToolBlock } from '../../agent/types' +import { formatChildActivityLabel } from './explore-peek-summary' import { useChatStore } from '../../store/chat-store' import { threadHasPendingRuntimeWork } from '../../store/chat-store-runtime-helpers' import { useTimelineStores } from './use-timeline-stores' @@ -428,6 +429,14 @@ export function MessageTimeline({ onExtensionCommand }: Props): ReactElement { const { t } = useTranslation('common') + const threadLoadingId = useChatStore((state) => state.threadLoadingId) + const cancelToolCall = useChatStore((state) => state.cancelToolCall) + const handleCancelToolCall = useCallback(async (block: ToolBlock): Promise => { + if (!activeThreadId || !block.turnId) return false + const callId = typeof block.meta?.callId === 'string' ? block.meta.callId : '' + if (!callId) return false + return cancelToolCall(activeThreadId, block.turnId, callId) + }, [activeThreadId, cancelToolCall]) const { route, workspaceRoot, @@ -727,7 +736,17 @@ export function MessageTimeline({
- {!hasContent || !activeThreadId ? ( + {activeThreadId && threadLoadingId === activeThreadId ? ( +
+
+
+
+
+ ) : !hasContent || !activeThreadId ? ( void reviewChangesDisabled?: boolean onOpenChildThread?: OpenChildThreadHandler + onCancelToolCall?: (block: ToolBlock) => Promise onComponentPrototypePrompt?: (prompt: string) => void filePreviewWorkspaceRoot: string viewportRef: RefObject @@ -989,6 +1011,7 @@ export function ConversationTurn({ onReviewChanges, reviewChangesDisabled = false, onOpenChildThread, + onCancelToolCall, onComponentPrototypePrompt, filePreviewWorkspaceRoot, viewportRef, @@ -1086,14 +1109,33 @@ export function ConversationTurn({ workProcessBlocks.length > 0 || (runtimeErrorBlocks.length > 0 && typeof durationMs === 'number') const showLiveProgress = isProcessing - const showLiveThinking = Boolean(liveProcessText.trim()) const liveToolBlock = useMemo( () => [...workProcessBlocks].reverse().find( + (block): block is Extract => + block.kind === 'tool' && block.status === 'running' + ) ?? [...workProcessBlocks].reverse().find( (block): block is Extract => block.kind === 'tool' ), [workProcessBlocks] ) + const liveChildActivityLabel = useMemo(() => { + if (!liveToolBlock) return undefined + const child = liveToolBlock.meta?.child + if (!child || typeof child !== 'object' || Array.isArray(child)) return undefined + const activity = (child as { + activity?: { phase?: RuntimeChildActivity['phase']; label?: string; toolName?: string; startedAt?: string; updatedAt?: string } + }).activity + if (!activity?.label?.trim()) return undefined + return formatChildActivityLabel({ + phase: activity.phase ?? 'tool', + label: activity.label.trim(), + ...(activity.toolName?.trim() ? { toolName: activity.toolName.trim() } : {}), + startedAt: activity.startedAt ?? '', + updatedAt: activity.updatedAt ?? '' + }) + }, [liveToolBlock]) + const showLiveThinking = Boolean(liveProcessText.trim()) && !liveChildActivityLabel && !liveToolBlock const forkFromTurn = async (): Promise => { if (!allowMainThreadActions || !forkTurnId || forking) return setForking(true) @@ -1144,6 +1186,7 @@ export function ConversationTurn({ workspaceRoot={filePreviewWorkspaceRoot} viewportRef={viewportRef} onOpenChildThread={onOpenChildThread} + onCancelToolCall={onCancelToolCall} allowThreadActions={allowMainThreadActions} /> ))} @@ -1228,7 +1271,11 @@ export function ConversationTurn({ ) : null} {showLiveProgress ? ( - + ) : null}
) @@ -1236,10 +1283,12 @@ export function ConversationTurn({ function LiveTurnProgressRow({ tool, - thinking + thinking, + activityLabel }: { tool?: Extract thinking: boolean + activityLabel?: string }): ReactElement { const { t, i18n } = useTranslation('common') const swimMode = useWorkLogoSwimMode(true) @@ -1256,13 +1305,15 @@ function LiveTurnProgressRow({ swimLabelKey as UiPluginLabelKey, i18n.language ?? 'zh' ) - const label = thinking - ? t('thinkingNow') - : tool - ? t('workingToolAction', { action: summarizeToolBlock(tool, t) }) - : ikunModeOn - ? t(IKUN_WORK_LOGO_VARIANT_LABEL_KEYS[ikunVariant]) - : pluginLabel ?? t(swimLabelKey) + const label = activityLabel + ? t('workingToolAction', { action: activityLabel }) + : thinking + ? t('thinkingNow') + : tool + ? t('workingToolAction', { action: summarizeToolBlock(tool, t) }) + : ikunModeOn + ? t(IKUN_WORK_LOGO_VARIANT_LABEL_KEYS[ikunVariant]) + : pluginLabel ?? t(swimLabelKey) return ( ( prev.onReviewChanges === next.onReviewChanges && prev.reviewChangesDisabled === next.reviewChangesDisabled && prev.onOpenChildThread === next.onOpenChildThread && + prev.onCancelToolCall === next.onCancelToolCall && prev.onComponentPrototypePrompt === next.onComponentPrototypePrompt && prev.filePreviewWorkspaceRoot === next.filePreviewWorkspaceRoot && prev.compactCards === next.compactCards && diff --git a/src/renderer/src/components/chat/SubagentCallCard.test.ts b/src/renderer/src/components/chat/SubagentCallCard.test.ts index 4eb899709..06ee28453 100644 --- a/src/renderer/src/components/chat/SubagentCallCard.test.ts +++ b/src/renderer/src/components/chat/SubagentCallCard.test.ts @@ -4,6 +4,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ToolBlock } from '../../agent/types' import { parseDelegateDetail, SubagentCallCard, SubagentGroup } from './SubagentCallCard' +const selectThread = vi.fn(async () => undefined) + +vi.mock('../../store/chat-store', () => ({ + useChatStore: (selector: (state: { selectThread: typeof selectThread }) => unknown) => + selector({ selectThread }) +})) + vi.mock('react-i18next', () => { const labels: Record = { subagentAgentLabel: 'Agent', @@ -16,13 +23,31 @@ vi.mock('react-i18next', () => { subagentStatusFailed: 'Failed', subagentStatusAwaiting: 'Awaiting approval', subagentOpenSession: 'Open sub-session', - subagentGeneratedBadge: 'Generated' + subagentOpenSessionShort: 'Open', + subagentGeneratedBadge: 'Generated', + exploreKindBadge: 'Explore', + exploreTaskDefaultTitle: 'Explore task', + exploreViewProcess: 'View explore process', + exploreViewProcessShort: 'Open', + exploreViewProcessSteps: 'View explore process · {{count}} steps', + exploreExpandConclusion: 'Show conclusion', + explorePeekPreview: 'Preview', + subagentSwarmTitle: '{{count}} subagents', + subagentSwarmRunning: '{{count}} running', + subagentSwarmQueued: '{{count}} queued', + subagentSwarmDone: '{{count}} done', + 'subagentsPanel.role.explore.name': 'Repository Explorer', + 'subagentsPanel.role.general.name': 'General Agent' } return { initReactI18next: { type: '3rdParty', init: () => undefined }, useTranslation: () => ({ - t: (key: string, fallback?: string | { defaultValue?: string }) => - labels[key] ?? (typeof fallback === 'string' ? fallback : fallback?.defaultValue) ?? key + t: (key: string, fallback?: string | { defaultValue?: string; count?: number }) => { + if (typeof fallback === 'object' && fallback && 'count' in fallback && key === 'exploreViewProcessSteps') { + return `View explore process · ${fallback.count} steps` + } + return labels[key] ?? (typeof fallback === 'string' ? fallback : fallback?.defaultValue) ?? key + } }) } }) @@ -54,6 +79,22 @@ describe('parseDelegateDetail', () => { generatedAgentName: 'Browser QA Specialist' }) }) + + it('reads explore_agent title and query from the tool payload', () => { + expect(parseDelegateDetail(JSON.stringify({ + childId: 'child_explore', + status: 'running', + title: 'Voice transcription flow', + query: 'Find how speech transcription is wired', + profile: 'explore' + }))).toMatchObject({ + childId: 'child_explore', + status: 'running', + title: 'Voice transcription flow', + query: 'Find how speech transcription is wired', + profile: 'explore' + }) + }) }) describe('SubagentCallCard route metadata', () => { @@ -109,7 +150,7 @@ describe('SubagentCallCard route metadata', () => { expect(instanceText(metadata)).toContain('IPC Investigator (generated:ipc-investigator:12345678)') }) - it('labels missing legacy identity and model instead of inferring current settings', async () => { + it('labels missing legacy identity and omits an empty model instead of showing Not recorded', async () => { await act(async () => { renderer = create(createElement(SubagentCallCard, { block: childBlock(undefined, { summary: 'Legacy result.' }) @@ -118,8 +159,9 @@ describe('SubagentCallCard route metadata', () => { const metadata = renderer!.root.findByProps({ 'data-testid': 'subagent-route-metadata' }) expect(metadata.props['data-agent-id']).toBe('') - expect(metadata.props['data-model']).toBe('Not recorded') - expect(instanceText(metadata).match(/Not recorded/g)).toHaveLength(2) + expect(metadata.props['data-model']).toBe('') + expect(instanceText(metadata)).toContain('Not recorded') + expect(instanceText(metadata)).not.toContain('Model') }) it('shows independently comparable route metadata for every grouped child row', async () => { @@ -151,6 +193,195 @@ describe('SubagentCallCard route metadata', () => { expect(rows.map((row) => row.props['data-agent-id'])).toEqual(['general', 'explore']) expect(rows.map((row) => row.props['data-model'])).toEqual(['gpt-5.6-sol', 'gpt-5.6-terra']) }) + + it('renders an all-explore cluster as independent full cards without a subagent swarm header', async () => { + const onOpenChildThread = vi.fn() + await act(async () => { + renderer = create(createElement(SubagentGroup, { + onOpenChildThread, + blocks: [ + exploreChildBlock({ + id: 'tool_explore_a', + childId: 'child_a', + childSeq: 1, + title: 'Packaging config', + summary: 'Checked packaging scripts.' + }), + exploreChildBlock({ + id: 'tool_explore_b', + childId: 'child_b', + childSeq: 2, + title: 'Release workflow', + summary: 'Checked release.yml.' + }) + ] + })) + }) + + const text = instanceText(renderer!.root) + expect(renderer!.root.findByProps({ 'data-testid': 'explore-independent-stack' })).toBeTruthy() + expect(renderer!.root.findAllByProps({ 'data-testid': 'subagent-call-card' })).toHaveLength(2) + expect(text).toContain('Packaging config') + expect(text).toContain('Release workflow') + expect(text).not.toContain('subagents') + expect(text).not.toContain('{{count}} subagents') + + const openButtons = renderer!.root.findAllByProps({ 'data-testid': 'explore-open-process-button' }) + expect(openButtons.length).toBeGreaterThanOrEqual(1) + await act(async () => { + openButtons[0].props.onClick({ stopPropagation() {} }) + }) + expect(onOpenChildThread).toHaveBeenCalledWith('child_a') + }) + + it('prefers explore title and live activity on a running explore_agent card', async () => { + await act(async () => { + renderer = create(createElement(SubagentCallCard, { + block: { + kind: 'tool', + id: 'tool_explore_live', + createdAt: '2026-08-07T00:00:00.000Z', + summary: 'explore_agent', + status: 'running', + toolKind: 'tool_call', + detail: JSON.stringify({ + childId: 'child_voice', + status: 'running', + title: 'Voice transcription flow', + query: 'Trace speech transcription', + profile: 'explore' + }), + meta: { + toolName: 'explore_agent', + child: { + parentThreadId: 'thread_parent', + parentTurnId: 'turn_parent', + childId: 'child_voice', + childLabel: 'Voice transcription flow', + childProfile: 'explore', + childProfileName: 'Repository Explorer', + childModel: 'deepseek-v4-flash', + childStatus: 'running', + childSeq: 1, + activity: { + phase: 'tool', + label: 'Reading tool timeline UI', + toolName: 'read', + startedAt: '2026-08-07T00:00:00.000Z', + updatedAt: '2026-08-07T00:00:02.000Z' + } + } + } + } + })) + }) + + expect(instanceText(renderer!.root)).toContain('Explore') + expect(instanceText(renderer!.root)).toContain('Voice transcription flow') + expect(instanceText(renderer!.root)).toContain('Reading tool timeline UI · read') + expect(instanceText(renderer!.root)).not.toContain('explore_agent') + expect(instanceText(renderer!.root)).not.toContain('Not recorded') + const card = renderer!.root.findByProps({ 'data-testid': 'subagent-call-card' }) + expect(card.props['data-activity-label']).toBe('Reading tool timeline UI · read') + expect(card.props['data-explore']).toBe('true') + }) + + it('shows the full conclusion by default and opens the child only via the process button', async () => { + selectThread.mockClear() + const onOpenChildThread = vi.fn() + const conclusion = [ + '已找到完整链路。结论如下:', + '## 1) 设置定义', + '- 类型定义: ProviderRetryConfig' + ].join('\n') + await act(async () => { + renderer = create(createElement(SubagentCallCard, { + onOpenChildThread, + block: { + kind: 'tool', + id: 'tool_explore_done', + createdAt: '2026-08-07T00:00:00.000Z', + summary: 'explore_agent', + status: 'success', + toolKind: 'tool_call', + detail: JSON.stringify({ + childId: 'child_tokens', + status: 'completed', + title: 'Token save label', + summary: conclusion, + profile: 'explore', + profileName: 'Repository Explorer', + model: 'deepseek-v4-flash', + toolInvocations: 5 + }), + meta: { + toolName: 'explore_agent', + child: { + parentThreadId: 'thread_parent', + parentTurnId: 'turn_parent', + childId: 'child_tokens', + childLabel: 'Token save label', + childProfile: 'explore', + childStatus: 'completed', + childSeq: 1, + toolInvocations: 5 + } + } + } + })) + }) + + const card = renderer!.root.findByProps({ 'data-testid': 'subagent-call-card' }) + expect(card.props['data-conclusion-expanded']).toBe('true') + expect(instanceText(renderer!.root)).toContain('已找到完整链路') + expect(instanceText(renderer!.root)).toContain('ProviderRetryConfig') + expect(instanceText(renderer!.root)).not.toContain('View explore process · 5 steps') + + const clickable = card.findAll((node) => node.props?.role === 'button')[0] + await act(async () => { + clickable.props.onClick() + }) + expect(onOpenChildThread).not.toHaveBeenCalled() + expect( + renderer!.root.findByProps({ 'data-testid': 'subagent-call-card' }).props['data-conclusion-expanded'] + ).toBe('false') + + const openProcess = renderer!.root.findByProps({ 'data-testid': 'explore-open-process-button' }) + await act(async () => { + openProcess.props.onClick({ stopPropagation() {} }) + }) + expect(onOpenChildThread).toHaveBeenCalledWith('child_tokens') + expect(selectThread).not.toHaveBeenCalled() + }) + + it('never titles a completed explore card with the raw tool name', async () => { + await act(async () => { + renderer = create(createElement(SubagentCallCard, { + block: { + kind: 'tool', + id: 'tool_explore_legacy', + createdAt: '2026-08-07T00:00:00.000Z', + summary: 'explore_agent', + status: 'success', + toolKind: 'tool_call', + detail: JSON.stringify({ + childId: 'child_legacy', + status: 'completed', + summary: 'Located save-tokens rendering in FloatingComposer.tsx', + toolInvocations: 5 + }), + meta: { toolName: 'explore_agent' } + } + })) + }) + + const text = instanceText(renderer!.root) + expect(text).toContain('Explore') + expect(text).toContain('Located save-tokens rendering in FloatingComposer.tsx') + expect(text).not.toMatch(/(^|[^a-z_])explore_agent([^a-z_]|$)/i) + expect(text).toContain('Repository Explorer') + expect(text).not.toContain('Not recorded') + }) }) function childBlock( @@ -188,6 +419,46 @@ function childBlock( } } +function exploreChildBlock(input: { + id: string + childId: string + childSeq: number + title: string + summary: string +}): ToolBlock { + return { + kind: 'tool', + id: input.id, + createdAt: '2026-08-07T00:00:00.000Z', + summary: 'explore_agent', + status: 'success', + toolKind: 'tool_call', + detail: JSON.stringify({ + childId: input.childId, + status: 'completed', + title: input.title, + summary: input.summary, + profile: 'explore', + profileName: 'Repository Explorer', + toolInvocations: 3 + }), + meta: { + toolName: 'explore_agent', + child: { + parentThreadId: 'thread_parent', + parentTurnId: 'turn_parent', + childId: input.childId, + childLabel: input.title, + childProfile: 'explore', + childProfileName: 'Repository Explorer', + childModel: 'deepseek-v4-flash', + childStatus: 'completed', + childSeq: input.childSeq + } + } + } +} + function instanceText(instance: ReactTestInstance): string { return instance.children .map((child) => typeof child === 'string' ? child : instanceText(child)) diff --git a/src/renderer/src/components/chat/SubagentCallCard.tsx b/src/renderer/src/components/chat/SubagentCallCard.tsx index 78e3ac2cc..e897828dc 100644 --- a/src/renderer/src/components/chat/SubagentCallCard.tsx +++ b/src/renderer/src/components/chat/SubagentCallCard.tsx @@ -2,7 +2,7 @@ import type { ReactElement } from 'react' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import type { TFunction } from 'i18next' -import { Check, ChevronDown, ChevronRight, ExternalLink, Hourglass, Loader2 } from 'lucide-react' +import { Check, ChevronDown, ChevronRight, Eye, Hourglass, Loader2 } from 'lucide-react' import type { ChatBlock, ToolBlock } from '../../agent/types' import { useChatStore } from '../../store/chat-store' import { AgentKun } from '../subagents/AgentKun' @@ -15,6 +15,18 @@ import { useSubagentReducedMotion } from '../subagents/SubagentLiveness' import { BUILTIN_AGENT_CATALOG_BY_ID } from '../../../../../kun/src/delegation/builtin-agent-catalog' +import { AssistantMarkdown } from './AssistantMarkdown' +import { ExplorePeekPopover } from './ExplorePeekPopover' +import { + firstUsefulLine, + isBareSubagentToolName, + isExploreToolBlock, + resolveExploreTaskTitle +} from './explore-card-copy' +import { + formatChildActivityLabel, + readChildActivityFromBlock +} from './explore-peek-summary' /** * "Kun Crew" — the subagent (`delegate_task`) visualization for the chat @@ -45,11 +57,15 @@ const KNOWN_POSE_IDS = new Set([ 'summary' ]) -/** Parsed shape of the `delegate_task` tool `detail` JSON (all optional). */ +/** Parsed shape of the `delegate_task` / `explore_agent` tool `detail` JSON (all optional). */ type DelegateDetail = { /** The child thread id — always present in the tool result, unlike `meta.child`. */ childId?: string status?: 'queued' | 'running' | 'completed' | 'failed' | 'aborted' + /** Short UI title from explore_agent (or early lifecycle updates). */ + title?: string + /** Narrow explore query from the initial tool arguments payload. */ + query?: string summary?: string error?: string profile?: string @@ -94,6 +110,8 @@ export function parseDelegateDetail(detail: string | undefined): DelegateDetail return { childId: str(obj.childId), status: status(obj.status), + title: str(obj.title), + query: str(obj.query), summary: str(obj.summary), error: str(obj.error), profile: str(obj.profile), @@ -252,6 +270,17 @@ function GeneratedPill({ t }: { t: TFunction<'common'> }): ReactElement { ) } +function ExploreKindBadge({ t }: { t: TFunction<'common'> }): ReactElement { + return ( + + {t('exploreKindBadge', { defaultValue: 'Explore' })} + + ) +} + function MetaChip({ children, title }: { children: React.ReactNode; title?: string }): ReactElement { return (