diff --git a/docs/KUN_CONFIG.md b/docs/KUN_CONFIG.md index 6418d1db1..efe9ba530 100644 --- a/docs/KUN_CONFIG.md +++ b/docs/KUN_CONFIG.md @@ -75,9 +75,9 @@ GUI 启动 Kun 时会按下面的顺序合并配置。 "contextCompaction": { "defaultSoftThreshold": 96000, "defaultHardThreshold": 108800, - "summaryMode": "heuristic", + "summaryMode": "model", "summaryTimeoutMs": 15000, - "summaryMaxTokens": 1200, + "summaryMaxTokens": 2048, "summaryInputMaxBytes": 98304 }, "runtime": { @@ -179,9 +179,9 @@ Kun 内置 DeepSeek V4 默认模型画像: "contextCompaction": { "defaultSoftThreshold": 96000, "defaultHardThreshold": 108800, - "summaryMode": "heuristic", + "summaryMode": "model", "summaryTimeoutMs": 15000, - "summaryMaxTokens": 1200, + "summaryMaxTokens": 2048, "summaryInputMaxBytes": 98304 } } @@ -191,7 +191,9 @@ Kun 内置 DeepSeek V4 默认模型画像: - `defaultSoftThreshold`: 未匹配到模型 profile 时,达到多少 input tokens 开始压缩。 - `defaultHardThreshold`: 未匹配到模型 profile 时,达到多少 input tokens 强制压缩。 -- `summaryMode`: `heuristic` 使用本地摘要骨架,`model` 会尝试调用模型生成摘要。 +- `summaryMode`: GUI 管理的配置默认并归一为 `model`。手工维护 `config.json` + 时仍可显式写 `heuristic` 使用本地摘要骨架;`model` 模式下模型摘要失败、 + 超时或返回空文本时会自动降级为本地摘要骨架。 - `summaryTimeoutMs`: 模型摘要调用超时时间。 - `summaryMaxTokens`: 模型摘要输出 token 上限。 - `summaryInputMaxBytes`: 摘要输入文本最大字节数。 diff --git a/kun/src/adapters/file/file-session-store.ts b/kun/src/adapters/file/file-session-store.ts index f522ffe8d..5802dece8 100644 --- a/kun/src/adapters/file/file-session-store.ts +++ b/kun/src/adapters/file/file-session-store.ts @@ -171,6 +171,11 @@ export class FileSessionStore implements SessionStore { this.itemsCacheVersion.clear() } + clearThreadMemory(threadId: string): void { + this.itemsCache.delete(threadId) + this.itemsCacheVersion.delete(threadId) + } + private itemsVersionOf(threadId: string): number { return this.itemsCacheVersion.get(threadId) ?? 0 } diff --git a/kun/src/adapters/hybrid/hybrid-session-store.ts b/kun/src/adapters/hybrid/hybrid-session-store.ts index 8fd9b4dc6..b1be9367d 100644 --- a/kun/src/adapters/hybrid/hybrid-session-store.ts +++ b/kun/src/adapters/hybrid/hybrid-session-store.ts @@ -80,4 +80,8 @@ export class HybridSessionStore implements SessionStore { async resetMemory(): Promise { await this.delegate.resetMemory() } + + clearThreadMemory(threadId: string): void { + this.delegate.clearThreadMemory(threadId) + } } diff --git a/kun/src/adapters/in-memory-event-bus.ts b/kun/src/adapters/in-memory-event-bus.ts index 32b098c19..3d6951d80 100644 --- a/kun/src/adapters/in-memory-event-bus.ts +++ b/kun/src/adapters/in-memory-event-bus.ts @@ -75,4 +75,11 @@ export class InMemoryEventBus implements EventBus { this.nextSeq.clear() this.highestSeqByThread.clear() } + + clearThread(threadId: string): void { + this.events.delete(threadId) + this.subscribers.delete(threadId) + this.nextSeq.delete(threadId) + this.highestSeqByThread.delete(threadId) + } } diff --git a/kun/src/adapters/in-memory-session-store.ts b/kun/src/adapters/in-memory-session-store.ts index 63b87d950..e1d09e6c3 100644 --- a/kun/src/adapters/in-memory-session-store.ts +++ b/kun/src/adapters/in-memory-session-store.ts @@ -119,4 +119,10 @@ export class InMemorySessionStore implements SessionStore { this.items.clear() this.sessions.clear() } + + clearThreadMemory(threadId: string): void { + this.events.delete(threadId) + this.items.delete(threadId) + this.sessions.delete(threadId) + } } diff --git a/kun/src/adapters/model/compat-model-client.retry.test.ts b/kun/src/adapters/model/compat-model-client.retry.test.ts index 610ebe89a..55ae89d19 100644 --- a/kun/src/adapters/model/compat-model-client.retry.test.ts +++ b/kun/src/adapters/model/compat-model-client.retry.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' import { CompatModelClient } from './compat-model-client.js' +import type { ModelRequestRetryConfig } from '../../config/kun-config.js' import type { ModelRequest, ModelStreamChunk } from '../../ports/model-client.js' -// Transient upstream gateway failures (502/503/504 from a load balancer) are -// momentary backend hiccups, not request errors. The client retries them a few -// times before failing the turn — see streamInner's transient-retry loop. +// Retry behavior is opt-in and provider-configurable. Keep the status list and +// delays explicit so these tests match the runtime contract. function request(signal?: AbortSignal): ModelRequest { return { @@ -40,13 +40,14 @@ function gatewayError(status: number): Response { ) } -function client(fetchImpl: typeof fetch): CompatModelClient { +function client(fetchImpl: typeof fetch, retry?: ModelRequestRetryConfig): CompatModelClient { return new CompatModelClient({ baseUrl: 'https://provider.example/v1', apiKey: 'sk-test', model: 'glm-5.1', endpointFormat: 'chat_completions', nonStreaming: true, + retry, fetchImpl }) } @@ -59,19 +60,22 @@ describe('CompatModelClient transient gateway retry', () => { return calls === 1 ? gatewayError(502) : okJson() }) as unknown as typeof fetch - const chunks = await drain(client(fetchImpl).stream(request())) + const chunks = await drain( + client(fetchImpl, { maxAttempts: 1, initialDelayMs: 0, httpStatusCodes: [502] }).stream(request()) + ) expect(calls).toBe(2) + expect(chunks).toContainEqual({ kind: 'retrying', status: 502, attempt: 1, maxAttempts: 1, delayMs: 0 }) expect(chunks.some((c) => c.kind === 'assistant_text_delta')).toBe(true) expect(chunks.at(-1)).toEqual({ kind: 'completed', stopReason: 'stop' }) expect(chunks.some((c) => c.kind === 'error')).toBe(false) }) - it('does not retry a non-transient 500', async () => { + it('does not retry by default', async () => { let calls = 0 const fetchImpl = (async () => { calls += 1 - return gatewayError(500) + return gatewayError(502) }) as unknown as typeof fetch const chunks = await drain(client(fetchImpl).stream(request())) @@ -116,7 +120,11 @@ describe('CompatModelClient transient gateway retry', () => { return gatewayError(503) }) as unknown as typeof fetch - const chunks = await drain(client(fetchImpl).stream(request(controller.signal))) + const chunks = await drain( + client(fetchImpl, { maxAttempts: 1, initialDelayMs: 1_000, httpStatusCodes: [503] }).stream( + request(controller.signal) + ) + ) expect(calls).toBe(1) expect(chunks.some((c) => c.kind === 'error')).toBe(true) diff --git a/kun/src/adapters/model/compat-model-client.ts b/kun/src/adapters/model/compat-model-client.ts index e418b2259..77d00f51d 100644 --- a/kun/src/adapters/model/compat-model-client.ts +++ b/kun/src/adapters/model/compat-model-client.ts @@ -9,6 +9,10 @@ import { isToolResultBridgeItem, repairModelHistoryItems } from '../../domain/mo import { extractToolResultImages, toolResultTextWithoutImages } from '../../loop/tool-result-image.js' import { repairToolArguments } from './tool-argument-repair.js' import { isDeepSeekHost, probeDeepSeekReachable } from './model-error-probe.js' +import { + DEFAULT_MODEL_REQUEST_RETRY_CONFIG, + type ModelRequestRetryConfig +} from '../../config/kun-config.js' import { DEFAULT_MODEL_ENDPOINT_FORMAT, isCustomModelEndpointFormat, @@ -44,6 +48,8 @@ export type CompatModelClientConfig = { nonStreaming?: boolean /** Maximum idle time between streaming chunks before the turn fails. */ streamIdleTimeoutMs?: number + /** 流式响应开始前,遇到临时失败或限流响应时使用的 HTTP 重试策略。 */ + retry?: ModelRequestRetryConfig /** Optional model capability resolver used for provider-specific reasoning translation. */ modelCapabilities?: (model: string) => ModelCapabilityMetadata /** Optional troubleshooting sink that captures each request body + raw output. */ @@ -264,18 +270,23 @@ export class CompatModelClient implements ModelClient { round.url = redactUrlForLog(url) } const headers = this.buildHeaders(stream, endpointFormat) + const retry = normalizeModelRequestRetryConfig(this.config.retry) + const retryStatuses = new Set(retry.httpStatusCodes) let result = await this.postChatCompletion(url, headers, body, request.abortSignal) - // Retry transient gateway failures (502/503/504) a few times before giving - // up. These are upstream load-balancer hiccups (e.g. an ALB returning - // "502 Bad Gateway"), not request errors — failing the whole turn on the - // first blip is needlessly fragile, especially for flaky providers. No - // response body has been streamed yet, so re-POSTing the same request is - // safe. Aborts short-circuit the backoff. - for (let attempt = 0; attempt < MAX_TRANSIENT_RETRIES; attempt += 1) { + for (let attempt = 0; attempt < retry.maxAttempts; attempt += 1) { if (result.kind === 'error') break - if (result.response.ok || !TRANSIENT_RETRY_STATUSES.has(result.response.status)) break + if (result.response.ok || !retryStatuses.has(result.response.status)) break + const delayMs = retryDelayMs(result.response, retry.initialDelayMs, attempt) + const status = result.response.status await result.response.body?.cancel().catch(() => {}) - const aborted = await sleepWithAbort(TRANSIENT_RETRY_BASE_MS * 2 ** attempt, request.abortSignal) + yield { + kind: 'retrying', + status, + attempt: attempt + 1, + maxAttempts: retry.maxAttempts, + delayMs + } + const aborted = await sleepWithAbort(delayMs, request.abortSignal) if (aborted || request.abortSignal.aborted) { yield { kind: 'error', message: 'request was aborted during retry backoff' } return @@ -2394,11 +2405,55 @@ function normalizeReasoningEffortValue(effort: string | undefined): NormalizedRe } } -// Transient upstream gateway statuses worth retrying — load balancers and -// reverse proxies return these for momentary backend unavailability. -const TRANSIENT_RETRY_STATUSES = new Set([502, 503, 504]) -const MAX_TRANSIENT_RETRIES = 2 -const TRANSIENT_RETRY_BASE_MS = 500 +function normalizeModelRequestRetryConfig(input: ModelRequestRetryConfig | undefined): { + maxAttempts: number + initialDelayMs: number + httpStatusCodes: number[] +} { + const defaults = DEFAULT_MODEL_REQUEST_RETRY_CONFIG + return { + maxAttempts: boundedNonNegativeInteger(input?.maxAttempts, defaults.maxAttempts, 10), + initialDelayMs: boundedNonNegativeInteger(input?.initialDelayMs, defaults.initialDelayMs, 600_000), + httpStatusCodes: normalizeRetryHttpStatusCodes(input?.httpStatusCodes, defaults.httpStatusCodes) + } +} + +function normalizeRetryHttpStatusCodes(input: unknown, fallback: readonly number[]): number[] { + const values = Array.isArray(input) ? input : fallback + const codes = new Set() + for (const raw of values) { + const code = typeof raw === 'number' ? raw : Number(raw) + if (!Number.isInteger(code) || code < 400 || code > 599) continue + codes.add(code) + } + return codes.size > 0 ? [...codes].sort((a, b) => a - b) : [...fallback] +} + +function boundedNonNegativeInteger(value: unknown, fallback: number, max: number): number { + const num = typeof value === 'number' ? value : Number(value) + if (!Number.isFinite(num)) return fallback + return Math.min(max, Math.max(0, Math.round(num))) +} + +function retryDelayMs(response: Response, initialDelayMs: number, attempt: number): number { + const retryAfterMs = parseRetryAfterMs(response.headers.get('retry-after')) + if (retryAfterMs !== undefined) return retryAfterMs + const exponential = Math.min(600_000, initialDelayMs * 2 ** attempt) + if (exponential <= 0) return 0 + return Math.round(exponential * (0.8 + Math.random() * 0.4)) +} + +function parseRetryAfterMs(value: string | null): number | undefined { + const trimmed = value?.trim() + if (!trimmed) return undefined + const seconds = Number(trimmed) + if (Number.isFinite(seconds) && seconds >= 0) { + return Math.min(600_000, Math.round(seconds * 1000)) + } + const dateMs = Date.parse(trimmed) + if (!Number.isFinite(dateMs)) return undefined + return Math.min(600_000, Math.max(0, dateMs - Date.now())) +} /** Sleep `ms`, resolving early to `true` if the signal aborts first. */ function sleepWithAbort(ms: number, signal: AbortSignal): Promise { diff --git a/kun/src/adapters/tool/builtin-repo-map-tool.test.ts b/kun/src/adapters/tool/builtin-repo-map-tool.test.ts index ba4ffd2d3..321ea2b00 100644 --- a/kun/src/adapters/tool/builtin-repo-map-tool.test.ts +++ b/kun/src/adapters/tool/builtin-repo-map-tool.test.ts @@ -39,6 +39,12 @@ async function createFixture(): Promise { return root } +async function createEmptyFixture(): Promise { + const root = await mkdtemp(join(tmpdir(), 'kun-repo-map-cache-')) + tempRoots.push(root) + return root +} + function context(workspace: string): ToolHostContext { return { threadId: 'thread_repo_map', @@ -95,4 +101,23 @@ describe('repo_map tool', () => { expect((cached.output as { cache: { hit: boolean } }).cache.hit).toBe(true) expect(SUBAGENT_READ_ONLY_TOOL_NAMES).toContain('repo_map') }) + + it('evicts the least recently used workspace when the cache reaches its limit', async () => { + const first = await createEmptyFixture() + const tool = createRepoMapLocalTool() + await tool.execute({}, context(first)) + + const others: string[] = [] + for (let index = 0; index < 7; index += 1) { + const root = await createEmptyFixture() + others.push(root) + await tool.execute({}, context(root)) + } + expect((await tool.execute({}, context(first))).output).toMatchObject({ cache: { hit: true } }) + await tool.execute({}, context(await createEmptyFixture())) + + expect((await tool.execute({}, context(first))).output).toMatchObject({ cache: { hit: true } }) + const rebuilt = await tool.execute({}, context(others[0]!)) + expect((rebuilt.output as { cache: { hit: boolean } }).cache.hit).toBe(false) + }, 10_000) }) diff --git a/kun/src/adapters/tool/builtin-repo-map-tool.ts b/kun/src/adapters/tool/builtin-repo-map-tool.ts index 84cbf3a08..9d15e0221 100644 --- a/kun/src/adapters/tool/builtin-repo-map-tool.ts +++ b/kun/src/adapters/tool/builtin-repo-map-tool.ts @@ -16,6 +16,7 @@ const DEFAULT_REPO_MAP_MAX_FILES = 20 const DEFAULT_REPO_MAP_MAX_SYMBOLS = 12 const DEFAULT_REPO_MAP_SCAN_LIMIT = 2500 const REPO_MAP_CACHE_TTL_MS = 30_000 +const REPO_MAP_CACHE_MAX_ENTRIES = 8 const MAX_SYMBOL_BYTES = 512 * 1024 const MAX_GIT_RECENT_FILES = 250 const INDEX_CONCURRENCY = 12 @@ -99,6 +100,17 @@ type RepoMapCacheEntry = { const repoMapCache = new Map() +function pruneRepoMapCache(now: number): void { + for (const [key, entry] of repoMapCache) { + if (entry.expiresAt <= now) repoMapCache.delete(key) + } + while (repoMapCache.size > REPO_MAP_CACHE_MAX_ENTRIES) { + const oldest = repoMapCache.keys().next().value + if (oldest === undefined) break + repoMapCache.delete(oldest) + } +} + export function createRepoMapLocalTool(): LocalTool { return { name: 'repo_map', @@ -148,8 +160,9 @@ export function createRepoMapLocalTool(): LocalTool { const { workspaceRoot, absolutePath, relativePath } = await resolveWorkspacePath(rawPath, context) const cacheKey = `${workspaceRoot}\0${absolutePath}` const gitHead = await gitHeadForWorkspace(workspaceRoot) - const cached = repoMapCache.get(cacheKey) const now = Date.now() + pruneRepoMapCache(now) + const cached = repoMapCache.get(cacheKey) const cacheHit = Boolean( cached && !refresh && @@ -167,11 +180,16 @@ export function createRepoMapLocalTool(): LocalTool { signal: context.abortSignal }) if (!cacheHit) { + repoMapCache.delete(cacheKey) repoMapCache.set(cacheKey, { index, expiresAt: now + REPO_MAP_CACHE_TTL_MS, scanLimit: maxScanFiles }) + pruneRepoMapCache(now) + } else { + repoMapCache.delete(cacheKey) + repoMapCache.set(cacheKey, cached!) } const ranked = rankRepoMapFiles(index.files, query, index.recentFiles) diff --git a/kun/src/adapters/tool/builtin-tool-types.ts b/kun/src/adapters/tool/builtin-tool-types.ts index e4a7da7e1..3f99dc28c 100644 --- a/kun/src/adapters/tool/builtin-tool-types.ts +++ b/kun/src/adapters/tool/builtin-tool-types.ts @@ -100,6 +100,7 @@ export type BuiltinToolName = | 'lsp' | 'repo_map' | 'verify_changes' + | 'send_im_attachment' export const allBuiltinToolNames: Set = new Set([ 'read', 'bash', @@ -110,7 +111,8 @@ export const allBuiltinToolNames: Set = new Set([ 'ls', 'lsp', 'repo_map', - 'verify_changes' + 'verify_changes', + 'send_im_attachment' ]) export type ToolName = BuiltinToolName export const allToolNames: Set = allBuiltinToolNames diff --git a/kun/src/adapters/tool/builtin-tool-utils.symlink.test.ts b/kun/src/adapters/tool/builtin-tool-utils.symlink.test.ts index 0a360b6c7..654f36936 100644 --- a/kun/src/adapters/tool/builtin-tool-utils.symlink.test.ts +++ b/kun/src/adapters/tool/builtin-tool-utils.symlink.test.ts @@ -6,6 +6,8 @@ import type { ToolHostContext } from '../../ports/tool-host.js' import { resolveWorkspacePath } from './builtin-tool-utils.js' import { resolveBackgroundShellOutputPaths } from '../../services/background-shell-output.js' +const directoryLinkType = process.platform === 'win32' ? 'junction' : 'dir' + function context(workspace: string): ToolHostContext { return { threadId: 'thread_symlink', @@ -37,12 +39,12 @@ describe('resolveWorkspacePath symlink escape', () => { it('rejects a DANGLING symlink whose target is outside the workspace (write/create case)', async () => { // `outside` deliberately does NOT exist — realpath() reports ENOENT for the // link exactly as for a missing file. This is the hole the fix closes. - await symlink(outside, join(workspace, 'evil')) + await symlink(outside, join(workspace, 'evil'), directoryLinkType) await expect(resolveWorkspacePath('evil', context(workspace))).rejects.toThrow(/escapes the workspace root/) }) it('rejects a path that traverses through a dangling symlink to an outside dir', async () => { - await symlink(outside, join(workspace, 'dlink')) + await symlink(outside, join(workspace, 'dlink'), directoryLinkType) await expect(resolveWorkspacePath('dlink/sub/new.txt', context(workspace))).rejects.toThrow( /escapes the workspace root/ ) @@ -50,7 +52,7 @@ describe('resolveWorkspacePath symlink escape', () => { it('rejects an EXISTING symlink that points outside the workspace', async () => { await mkdir(outside, { recursive: true }) - await symlink(outside, join(workspace, 'link')) + await symlink(outside, join(workspace, 'link'), directoryLinkType) await expect(resolveWorkspacePath('link/file.txt', context(workspace))).rejects.toThrow( /escapes the workspace root/ ) @@ -58,9 +60,9 @@ describe('resolveWorkspacePath symlink escape', () => { it('allows a dangling symlink that stays inside the workspace', async () => { // Link target is absent but in-workspace — a legitimate write/create target. - await symlink(join(workspace, 'data', 'note.txt'), join(workspace, 'good')) - const resolved = await resolveWorkspacePath('good', context(workspace)) - expect(resolved.absolutePath).toBe(join(workspace, 'good')) + await symlink(join(workspace, 'data'), join(workspace, 'good'), directoryLinkType) + const resolved = await resolveWorkspacePath('good/note.txt', context(workspace)) + expect(resolved.absolutePath).toBe(join(workspace, 'good', 'note.txt')) }) it('allows creating a new nested file with no symlinks involved', async () => { diff --git a/kun/src/adapters/tool/builtin-tool-utils.ts b/kun/src/adapters/tool/builtin-tool-utils.ts index c2d63f6a1..cf77f59c6 100644 --- a/kun/src/adapters/tool/builtin-tool-utils.ts +++ b/kun/src/adapters/tool/builtin-tool-utils.ts @@ -75,7 +75,7 @@ export async function resolveWorkspacePath(inputPath: string, context: ToolHostC return { workspaceRoot: root, absolutePath: resolve(lexicalAbsolutePath), - relativePath: relative(root, resolve(lexicalAbsolutePath)) || '.' + relativePath: normalizeToolPath(relative(root, resolve(lexicalAbsolutePath)) || '.') } } // In full-access mode the workspace boundary is not enforced: the user has @@ -88,7 +88,7 @@ export async function resolveWorkspacePath(inputPath: string, context: ToolHostC return { workspaceRoot: root, absolutePath: lexicalAbsolutePath, - relativePath: relative(root, lexicalAbsolutePath) || '.' + relativePath: normalizeToolPath(relative(root, lexicalAbsolutePath) || '.') } } const resolvedRoot = await safeRealpath(root) @@ -110,7 +110,7 @@ export async function resolveWorkspacePath(inputPath: string, context: ToolHostC return { workspaceRoot: root, absolutePath: lexicalAbsolutePath, - relativePath: relative(root, lexicalAbsolutePath) || '.' + relativePath: normalizeToolPath(relative(root, lexicalAbsolutePath) || '.') } } diff --git a/kun/src/adapters/tool/builtin-tools.ts b/kun/src/adapters/tool/builtin-tools.ts index f25ac2e4e..b1212b39c 100644 --- a/kun/src/adapters/tool/builtin-tools.ts +++ b/kun/src/adapters/tool/builtin-tools.ts @@ -12,6 +12,7 @@ import { createReadLocalTool } from './builtin-read-tool.js' import { createFindLocalTool, createGrepLocalTool, createLsLocalTool } from './builtin-search-tools.js' import { createRepoMapLocalTool } from './builtin-repo-map-tool.js' import { createVerifyChangesLocalTool } from './builtin-verify-tool.js' +import { createSendImAttachmentLocalTool } from './im-attachment-tool.js' export * from './builtin-tool-types.js' export * from './builtin-tool-operations.js' @@ -21,6 +22,7 @@ export * from './builtin-search-tools.js' export * from './builtin-repo-map-tool.js' export * from './builtin-bash-tool.js' export * from './builtin-verify-tool.js' +export * from './im-attachment-tool.js' export function createBuiltinLocalTool( toolName: BuiltinToolName, @@ -47,6 +49,8 @@ export function createBuiltinLocalTool( return createRepoMapLocalTool() case 'verify_changes': return createVerifyChangesLocalTool() + case 'send_im_attachment': + return createSendImAttachmentLocalTool() } } @@ -69,7 +73,8 @@ export function buildBuiltinLocalTools(options: BuiltinLocalToolsOptions = {}): createLsLocalTool(options.ls), createLspLocalTool(), createRepoMapLocalTool(), - createVerifyChangesLocalTool() + createVerifyChangesLocalTool(), + createSendImAttachmentLocalTool() ] } @@ -117,7 +122,8 @@ export function buildBuiltinLocalToolRecord( ls: createLsLocalTool(options.ls), lsp: createLspLocalTool(), repo_map: createRepoMapLocalTool(), - verify_changes: createVerifyChangesLocalTool() + verify_changes: createVerifyChangesLocalTool(), + send_im_attachment: createSendImAttachmentLocalTool() } } diff --git a/kun/src/adapters/tool/delegation-tool-provider.ts b/kun/src/adapters/tool/delegation-tool-provider.ts index f4ece7e7d..3686041dd 100644 --- a/kun/src/adapters/tool/delegation-tool-provider.ts +++ b/kun/src/adapters/tool/delegation-tool-provider.ts @@ -61,6 +61,7 @@ export function buildDelegationToolProviders(runtime: DelegationRuntime | undefi if (args.timeBudgetMs !== undefined && !isPositiveInteger(args.timeBudgetMs)) { return { output: { error: 'timeBudgetMs must be a positive integer' }, isError: true } } + const inheritedProviderId = context.modelProviderId?.trim() const record = await runtime.runChild({ parentThreadId: context.threadId, parentTurnId: context.turnId, @@ -69,6 +70,7 @@ export function buildDelegationToolProviders(runtime: DelegationRuntime | undefi workspace: typeof args.workspace === 'string' ? args.workspace : context.workspace, ...(typeof args.model === 'string' ? { model: args.model } : {}), ...(typeof args.profile === 'string' ? { profile: args.profile } : {}), + ...(inheritedProviderId ? { inheritedProviderId } : {}), ...(context.guiDesignCanvas ? { guiDesignCanvas: true } : {}), ...(args.detach === true ? { detach: true } : {}), ...(isPositiveInteger(args.tokenBudget) ? { tokenBudget: args.tokenBudget } : {}), @@ -79,7 +81,12 @@ export function buildDelegationToolProviders(runtime: DelegationRuntime | undefi // child is still running — not only after it completes. onStart: (childId, profile) => { void onUpdate?.({ - output: { childId, status: 'running', ...(profile ? { profile } : {}) }, + output: { + childId, + status: 'running', + detached: args.detach === true, + ...(profile ? { profile } : {}) + }, isError: false }) }, @@ -89,6 +96,7 @@ export function buildDelegationToolProviders(runtime: DelegationRuntime | undefi output: { childId: record.id, status: record.status, + detached: record.detached === true, summary: record.summary, error: record.error, evidence: record.evidence, diff --git a/kun/src/adapters/tool/im-attachment-tool.test.ts b/kun/src/adapters/tool/im-attachment-tool.test.ts new file mode 100644 index 000000000..6a5622528 --- /dev/null +++ b/kun/src/adapters/tool/im-attachment-tool.test.ts @@ -0,0 +1,119 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { ToolHostContext } from '../../ports/tool-host.js' +import { createSendImAttachmentLocalTool } from './im-attachment-tool.js' +import { LocalToolHost } from './local-tool-host.js' + +function baseContext(workspace: string, imContext: boolean): ToolHostContext { + return { + threadId: 'thr_1', + turnId: 'turn_1', + workspace, + imContext, + approvalPolicy: 'auto', + sandboxMode: 'danger-full-access', + abortSignal: new AbortController().signal, + awaitApproval: vi.fn(async () => 'allow' as const) + } +} + +describe('send_im_attachment tool', () => { + it('keeps a stable tool catalog and returns attachment file metadata for IM turns', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'kun-im-attachment-tool-')) + const dir = join(workspaceRoot, 'out') + const filePath = join(dir, 'hello.txt') + await mkdir(dir, { recursive: true }) + await writeFile(filePath, 'hello') + try { + const host = new LocalToolHost({ tools: [createSendImAttachmentLocalTool()] }) + + await expect(host.listTools(baseContext(workspaceRoot, true))) + .resolves.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'send_im_attachment' })])) + await expect(host.listTools(baseContext(workspaceRoot, false))) + .resolves.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'send_im_attachment' })])) + + const result = await host.execute( + { + callId: 'call_attachment', + toolName: 'send_im_attachment', + arguments: { path: 'out/hello.txt' } + }, + baseContext(workspaceRoot, true) + ) + + expect(result.item).toMatchObject({ + kind: 'tool_result', + toolName: 'send_im_attachment', + isError: false, + output: { + status: 'queued_for_im_attachment_delivery', + files: [ + { + relativePath: 'out/hello.txt', + fileName: 'hello.txt', + bytes: 5 + } + ] + } + }) + } finally { + await rm(workspaceRoot, { recursive: true, force: true }) + } + }) + + it('rejects execution outside IM turns while keeping the schema advertised', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'kun-im-attachment-tool-')) + const filePath = join(workspaceRoot, 'hello.txt') + await writeFile(filePath, 'hello') + try { + const host = new LocalToolHost({ tools: [createSendImAttachmentLocalTool()] }) + const result = await host.execute( + { + callId: 'call_attachment_non_im', + toolName: 'send_im_attachment', + arguments: { path: 'hello.txt' } + }, + baseContext(workspaceRoot, false) + ) + + expect(result.item).toMatchObject({ + kind: 'tool_result', + toolName: 'send_im_attachment', + isError: true, + output: { error: 'send_im_attachment is only available for IM turns' } + }) + } finally { + await rm(workspaceRoot, { recursive: true, force: true }) + } + }) + + it('rejects files outside the IM workspace', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'kun-im-attachment-tool-')) + const outsideRoot = await mkdtemp(join(tmpdir(), 'kun-im-attachment-outside-')) + const outsidePath = join(outsideRoot, 'secret.txt') + await writeFile(outsidePath, 'secret') + try { + const host = new LocalToolHost({ tools: [createSendImAttachmentLocalTool()] }) + const result = await host.execute( + { + callId: 'call_attachment_outside', + toolName: 'send_im_attachment', + arguments: { path: outsidePath } + }, + baseContext(workspaceRoot, true) + ) + + expect(result.item).toMatchObject({ + kind: 'tool_result', + toolName: 'send_im_attachment', + isError: true, + output: { error: expect.stringContaining('path escapes the workspace root') } + }) + } finally { + await rm(workspaceRoot, { recursive: true, force: true }) + await rm(outsideRoot, { recursive: true, force: true }) + } + }) +}) diff --git a/kun/src/adapters/tool/im-attachment-tool.ts b/kun/src/adapters/tool/im-attachment-tool.ts new file mode 100644 index 000000000..90c1e5d9a --- /dev/null +++ b/kun/src/adapters/tool/im-attachment-tool.ts @@ -0,0 +1,140 @@ +import { realpath, stat } from 'node:fs/promises' +import { basename, isAbsolute, relative, resolve, sep } from 'node:path' +import type { LocalTool } from './local-tool-host.js' +import { withToolBoundary, workspaceRoot } from './builtin-tool-utils.js' + +const MAX_IM_ATTACHMENT_BYTES = 50 * 1024 * 1024 +const MAX_IM_ATTACHMENTS = 3 + +function rawPaths(args: Record): string[] { + if (Array.isArray(args.paths)) { + return args.paths.filter((entry): entry is string => typeof entry === 'string') + } + return typeof args.path === 'string' ? [args.path] : [] +} + +function fileNameFor(args: Record, index: number, fallback: string): string { + if (typeof args.fileName === 'string' && index === 0 && args.fileName.trim()) { + return args.fileName.trim() + } + if (Array.isArray(args.fileNames)) { + const value = args.fileNames[index] + if (typeof value === 'string' && value.trim()) return value.trim() + } + return fallback +} + +async function resolveImAttachmentPath( + inputPath: string, + contextWorkspace: string +): Promise<{ + absolutePath: string + relativePath: string + fileName: string + bytes: number +}> { + const root = workspaceRoot(contextWorkspace) + const lexicalPath = isAbsolute(inputPath) ? resolve(inputPath) : resolve(root, inputPath) + const [realRoot, realFile] = await Promise.all([ + realpath(root), + realpath(lexicalPath) + ]) + const nativeRelativePath = relative(realRoot, realFile) + if (nativeRelativePath === '..' || nativeRelativePath.startsWith(`..${sep}`) || isAbsolute(nativeRelativePath)) { + throw new Error(`path escapes the workspace root: ${inputPath}`) + } + const fileStat = await stat(realFile) + if (!fileStat.isFile()) { + throw new Error(`attachment path is not a file: ${inputPath}`) + } + if (fileStat.size > MAX_IM_ATTACHMENT_BYTES) { + throw new Error(`attachment file is too large: ${inputPath}`) + } + return { + absolutePath: realFile, + relativePath: nativeRelativePath.replaceAll(sep, '/'), + fileName: basename(realFile), + bytes: fileStat.size + } +} + +export function createSendImAttachmentLocalTool(): LocalTool { + return { + name: 'send_im_attachment', + description: + 'Queue one or more existing workspace files to be sent back to the active IM chat as attachments. Use only when the user asks to receive a file, image, audio, video, or document through IM.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Single workspace-relative or absolute path to send.' + }, + paths: { + type: 'array', + items: { type: 'string' }, + maxItems: MAX_IM_ATTACHMENTS, + description: 'Multiple workspace-relative or absolute paths to send.' + }, + fileName: { + type: 'string', + description: 'Optional display file name for a single attachment.' + }, + fileNames: { + type: 'array', + items: { type: 'string' }, + maxItems: MAX_IM_ATTACHMENTS, + description: 'Optional display file names matching paths.' + }, + message: { + type: 'string', + description: 'Optional short text to include in the final reply.' + } + }, + additionalProperties: false + }, + policy: 'auto', + toolKind: 'tool_call', + execute: async (args, context) => withToolBoundary(async () => { + if (context.imContext !== true) { + return { + output: { error: 'send_im_attachment is only available for IM turns' }, + isError: true + } + } + const paths = rawPaths(args).map((entry) => entry.trim()).filter(Boolean) + if (paths.length === 0) { + return { output: { error: 'path or paths is required' }, isError: true } + } + if (paths.length > MAX_IM_ATTACHMENTS) { + return { + output: { error: `at most ${MAX_IM_ATTACHMENTS} attachments can be sent at once` }, + isError: true + } + } + const files = [] + const seen = new Set() + for (let index = 0; index < paths.length; index += 1) { + const inputPath = paths[index] + if (!inputPath) continue + const resolved = await resolveImAttachmentPath(inputPath, context.workspace) + if (seen.has(resolved.absolutePath)) continue + seen.add(resolved.absolutePath) + files.push({ + path: resolved.absolutePath, + absolutePath: resolved.absolutePath, + relativePath: resolved.relativePath, + fileName: fileNameFor(args, index, resolved.fileName), + bytes: resolved.bytes + }) + } + return { + output: { + files, + message: typeof args.message === 'string' ? args.message.trim() : '', + status: 'queued_for_im_attachment_delivery' + } + } + }) + } +} diff --git a/kun/src/adapters/tool/image-gen-tool-provider.ts b/kun/src/adapters/tool/image-gen-tool-provider.ts index 6047a1a22..a86c83373 100644 --- a/kun/src/adapters/tool/image-gen-tool-provider.ts +++ b/kun/src/adapters/tool/image-gen-tool-provider.ts @@ -150,14 +150,16 @@ function parseRatio(aspectRatio: string | undefined): { w: number; h: number } | /** * Whether the configured image protocol performs a GENUINE image-to-image edit * (real `/images/edits`). Allowlist on purpose: a new protocol defaults to "no - * edit" until its edit path is verified. MiniMax's reference feature is - * `subject_reference` = character/identity preservation, NOT a general edit, so - * routing canvas "edit this image" requests through it silently produces a fresh - * (wrong) generation — better to fail loudly and have the agent retry without - * references. `undefined` = the default factory path (OpenAI-compat /images/edits). + * edit" until its edit path is verified. Codex's Responses image_generation + * path accepts `input_image` references and an explicit `action: "edit"`. + * MiniMax's reference feature is `subject_reference` = character/identity + * preservation, NOT a general edit, so routing canvas "edit this image" + * requests through it silently produces a fresh (wrong) generation — better to + * fail loudly and have the agent retry without references. `undefined` = the + * default factory path (OpenAI-compat /images/edits). */ export function protocolSupportsImageEdit(protocol: string | undefined): boolean { - return protocol === undefined || protocol === 'openai-images' + return protocol === undefined || protocol === 'openai-images' || protocol === 'codex-responses-image' } export function buildImageGenToolProviders( @@ -267,7 +269,7 @@ export function buildImageGenToolProviders( if (endpoint === 'edits' && (error.status === 404 || error.status === 405 || error.status === 501)) { return toolError( 'edits_unsupported', - 'the configured image provider does not support reference images (/images/edits); retry generate_image without reference_image_paths' + 'the configured image provider does not support reference image edits; retry generate_image without reference_image_paths' ) } return toolError('provider_error', error.message, telemetry(startedAt, client.id)) @@ -774,7 +776,7 @@ export class CodexResponsesImageClient implements ImageGenClient { tools: [ { type: 'image_generation', - action: 'generate', + action: inputImages.length > 0 ? 'edit' : 'generate', model: request.model, quality: request.quality ?? 'auto', output_format: 'png', diff --git a/kun/src/adapters/tool/local-tool-host.test.ts b/kun/src/adapters/tool/local-tool-host.test.ts index 918277bf4..c1f019327 100644 --- a/kun/src/adapters/tool/local-tool-host.test.ts +++ b/kun/src/adapters/tool/local-tool-host.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { LocalToolHost, echoTool } from './local-tool-host.js' +import { LocalToolHost, echoTool, userInputTool } from './local-tool-host.js' import type { ToolHostContext } from '../../ports/tool-host.js' import { InMemoryArtifactStore } from '../../artifacts/artifact-store.js' @@ -57,4 +57,36 @@ describe('LocalToolHost approval policy', () => { const artifactId = String((result.item.output as Record).artifactId) expect(await artifactStore.get(artifactId)).toHaveLength(140 * 1024) }) + + it('keeps user input tools advertised without a GUI gate but rejects execution', async () => { + const host = new LocalToolHost({ tools: [echoTool, userInputTool] }) + const context = { + threadId: 'thread_1', + turnId: 'turn_1', + workspace: '/tmp/workspace', + approvalPolicy: 'auto', + sandboxMode: 'workspace-write', + abortSignal: new AbortController().signal, + awaitApproval: vi.fn(async () => 'allow' as const) + } satisfies ToolHostContext + + await expect(host.listTools(context)).resolves.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'user_input' })]) + ) + const result = await host.execute( + { + callId: 'call_input', + toolName: 'user_input', + arguments: { question: 'Continue?' } + }, + context + ) + + expect(result.item).toMatchObject({ + kind: 'tool_result', + toolName: 'user_input', + isError: true, + output: { error: 'GUI user input is not available in this runtime context' } + }) + }) }) diff --git a/kun/src/adapters/tool/local-tool-host.ts b/kun/src/adapters/tool/local-tool-host.ts index 421002a76..ef84b68f5 100644 --- a/kun/src/adapters/tool/local-tool-host.ts +++ b/kun/src/adapters/tool/local-tool-host.ts @@ -464,9 +464,6 @@ function createUserInputTool(name: string): LocalTool { required: [] }, policy: 'auto', - // Only advertised when the turn can actually resolve structured - // input (IM bridges and headless runs omit `awaitUserInput`). - shouldAdvertise: (context) => typeof context.awaitUserInput === 'function', execute: async (args, context) => { if (!context.awaitUserInput) { return { diff --git a/kun/src/adapters/tool/lsp-client.test.ts b/kun/src/adapters/tool/lsp-client.test.ts index ab357684a..64ebcd4fe 100644 --- a/kun/src/adapters/tool/lsp-client.test.ts +++ b/kun/src/adapters/tool/lsp-client.test.ts @@ -1,4 +1,6 @@ import { EventEmitter } from 'node:events' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' const { accessMock, spawnMock } = vi.hoisted(() => ({ @@ -241,24 +243,27 @@ describe('LSP notifications', () => { throw new Error(`Unexpected spawn: ${command}`) }) - const session = await acquireLspSession('/workspace/diagnostics', 'typescript') + const workspaceRoot = resolve('/workspace/diagnostics') + const filePath = join(workspaceRoot, 'app.ts') + const fileUri = pathToFileURL(filePath).href + const session = await acquireLspSession(workspaceRoot, 'typescript') emitJsonRpc(serverProcess as MockProcess, { jsonrpc: '2.0', method: 'textDocument/publishDiagnostics', params: { - uri: 'file:///workspace/diagnostics/app.ts', + uri: fileUri, diagnostics: [{ message: 'boom', severity: 1 }] } }) - expect(session.diagnostics.get('file:///workspace/diagnostics/app.ts')).toEqual([ + expect(session.diagnostics.get(fileUri)).toEqual([ { message: 'boom', severity: 1 } ]) - await expect(lspGetDiagnostics(session, '/workspace/diagnostics/app.ts')).resolves.toEqual({ + await expect(lspGetDiagnostics(session, filePath)).resolves.toEqual({ diagnostics: [{ message: 'boom', severity: 1 }], source: 'publishDiagnostics-cache' }) - releaseLspSession('/workspace/diagnostics', 'typescript') + releaseLspSession(workspaceRoot, 'typescript') }) it('logs server messages from window/logMessage notifications', async () => { diff --git a/kun/src/cli/cli-options.ts b/kun/src/cli/cli-options.ts index 57273b9b7..1f385eccc 100644 --- a/kun/src/cli/cli-options.ts +++ b/kun/src/cli/cli-options.ts @@ -10,6 +10,7 @@ import { DEFAULT_KUN_MODEL, DEFAULT_STORAGE_CONFIG, DEFAULT_TOOL_OUTPUT_LIMITS_CONFIG, + ModelRequestRetryConfigSchema, ModelConfigSchema, QualityConfigSchema, RolesConfigSchema, @@ -51,6 +52,7 @@ export const ServeOptionsSchema = z.object({ baseUrl: z.string().default('https://api.deepseek.com/beta'), modelProxyUrl: z.string().default(''), endpointFormat: z.preprocess(normalizeModelEndpointFormat, z.enum(MODEL_ENDPOINT_FORMATS)).default(DEFAULT_MODEL_ENDPOINT_FORMAT), + retry: ModelRequestRetryConfigSchema.optional(), model: z.string().default(DEFAULT_SERVE_MODEL), approvalPolicy: ApprovalPolicySchema.default(DEFAULT_APPROVAL_POLICY), sandboxMode: SandboxModeSchema.default(DEFAULT_SANDBOX_MODE), diff --git a/kun/src/cli/serve.ts b/kun/src/cli/serve.ts index 2d7be3770..08ca10389 100644 --- a/kun/src/cli/serve.ts +++ b/kun/src/cli/serve.ts @@ -107,6 +107,7 @@ export function parseServeOptions( : env.KUN_ENDPOINT_FORMAT as ServeOptions['endpointFormat'] | undefined ?? configServe.endpointFormat ?? DEFAULT_SERVE_OPTIONS.endpointFormat, + retry: configServe.retry ?? DEFAULT_SERVE_OPTIONS.retry, model: typeof raw.model === 'string' ? raw.model diff --git a/kun/src/config/kun-config.ts b/kun/src/config/kun-config.ts index 7c7e68504..fe4f32b90 100644 --- a/kun/src/config/kun-config.ts +++ b/kun/src/config/kun-config.ts @@ -33,6 +33,21 @@ export const DEFAULT_KUN_MODEL = 'deepseek-v4-pro' const PositiveInt = z.number().int().positive() const PositiveRatio = z.number().positive().max(1) +export const DEFAULT_MODEL_REQUEST_RETRY_CONFIG = { + maxAttempts: 0, + initialDelayMs: 3_000, + httpStatusCodes: [429, 503] +} as const + +export const ModelRequestRetryConfigSchema = z + .object({ + maxAttempts: z.number().int().min(0).max(10).default(DEFAULT_MODEL_REQUEST_RETRY_CONFIG.maxAttempts).optional(), + initialDelayMs: z.number().int().min(0).max(600_000).default(DEFAULT_MODEL_REQUEST_RETRY_CONFIG.initialDelayMs).optional(), + httpStatusCodes: z.array(z.number().int().min(400).max(599)).max(64).default([...DEFAULT_MODEL_REQUEST_RETRY_CONFIG.httpStatusCodes]).optional() + }) + .strict() +export type ModelRequestRetryConfig = z.infer + export const ModelContextCompactionProfileConfigSchema = z .object({ softRatio: PositiveRatio.optional(), @@ -241,6 +256,7 @@ export const ServeProviderConfigSchema = z .preprocess(normalizeModelEndpointFormat, z.enum(MODEL_ENDPOINT_FORMATS)) .default(DEFAULT_MODEL_ENDPOINT_FORMAT) .optional(), + retry: ModelRequestRetryConfigSchema.optional(), modelProxyUrl: z.string().optional(), headers: z.record(z.string(), z.string()).optional() }) @@ -269,6 +285,7 @@ export const KunServeConfigSchema = z normalizeModelEndpointFormat, z.enum(MODEL_ENDPOINT_FORMATS) ).default(DEFAULT_MODEL_ENDPOINT_FORMAT).optional(), + retry: ModelRequestRetryConfigSchema.optional(), model: z.string().min(1).optional(), approvalPolicy: ApprovalPolicySchema.default(DEFAULT_APPROVAL_POLICY).optional(), sandboxMode: SandboxModeSchema.default(DEFAULT_SANDBOX_MODE).optional(), diff --git a/kun/src/contracts/events.ts b/kun/src/contracts/events.ts index 05d07c98c..24b466c88 100644 --- a/kun/src/contracts/events.ts +++ b/kun/src/contracts/events.ts @@ -24,6 +24,7 @@ export const RuntimeEventKind = z.enum([ 'assistant_text_delta', 'assistant_reasoning_delta', 'tool_call_ready', + 'model_request_retry', 'tool_result_upload_wait', 'tool_storm_suppressed', 'tool_catalog_changed', @@ -77,6 +78,7 @@ const RuntimeEventBase = z.object({ childLabel: z.string().optional(), childStatus: z.enum(['queued', 'running', 'completed', 'failed', 'aborted']), childSeq: z.number().int().nonnegative(), + detached: z.boolean().optional(), // Observability metrics carried alongside the child lifecycle event so // the GUI can show prefix reuse, tool fan-out, timing, and cost per // subagent without a separate diagnostics fetch. @@ -177,6 +179,15 @@ export const ToolCallReadyEvent = RuntimeEventBase.extend({ }) export type ToolCallReadyEvent = z.infer +export const ModelRequestRetryEvent = RuntimeEventBase.extend({ + kind: z.literal('model_request_retry'), + status: z.number().int().min(100).max(599), + attempt: z.number().int().positive(), + maxAttempts: z.number().int().positive(), + delayMs: z.number().int().nonnegative() +}) +export type ModelRequestRetryEvent = z.infer + export const ToolUploadStatusEvent = RuntimeEventBase.extend({ kind: z.literal('tool_result_upload_wait'), status: z.literal('waiting'), @@ -285,6 +296,7 @@ export const RuntimeEvent = z.discriminatedUnion('kind', [ ApprovalEvent, UserInputEvent, ToolCallReadyEvent, + ModelRequestRetryEvent, ToolUploadStatusEvent, ToolStormSuppressedEvent, ToolCatalogEvent, diff --git a/kun/src/contracts/items.ts b/kun/src/contracts/items.ts index 622608384..08d04800b 100644 --- a/kun/src/contracts/items.ts +++ b/kun/src/contracts/items.ts @@ -51,7 +51,7 @@ export const UserFileReferenceSchema = z.object({ }) export type UserFileReference = z.infer -export const UserMessageSource = z.enum(['background_shell']) +export const UserMessageSource = z.enum(['background_shell', 'background_subagent']) export type UserMessageSource = z.infer export const UserTurnItem = TurnItemBase.extend({ diff --git a/kun/src/contracts/turns.ts b/kun/src/contracts/turns.ts index 91a90f718..5de2b26b3 100644 --- a/kun/src/contracts/turns.ts +++ b/kun/src/contracts/turns.ts @@ -108,6 +108,12 @@ export const TurnSchema = z.object({ * rejects calls to them instead of blocking on a GUI answer. */ disableUserInput: z.boolean().optional(), + /** + * True when this turn originated from an IM bridge. Kun exposes + * IM-only tools such as outbound attachment delivery only for these + * turns. + */ + imContext: z.boolean().optional(), error: z.string().optional() }) export type Turn = z.infer @@ -154,7 +160,12 @@ export const StartTurnRequest = z.object({ * user (IM bridges such as WeChat/Feishu, headless runs). The turn * runs without the `user_input`/`request_user_input` tools. */ - disableUserInput: z.boolean().optional() + disableUserInput: z.boolean().optional(), + /** + * True when the turn is handled through an IM bridge. This gates + * IM-only tool exposure separately from generic headless turns. + */ + imContext: z.boolean().optional() }) export type StartTurnRequest = z.input diff --git a/kun/src/delegation/child-agent-executor.ts b/kun/src/delegation/child-agent-executor.ts index e235f838b..28b9370d8 100644 --- a/kun/src/delegation/child-agent-executor.ts +++ b/kun/src/delegation/child-agent-executor.ts @@ -213,7 +213,20 @@ export function createChildAgentExecutor(options: ChildAgentExecutorOptions): Ch disableUserInput: true } }) - const status = await loop.runTurn(thread.id, started.turnId) + const abortChild = (): void => { + void turns.interruptTurn({ + threadId: thread.id, + turnId: started.turnId + }).catch(() => undefined) + } + if (input.signal.aborted) abortChild() + else input.signal.addEventListener('abort', abortChild, { once: true }) + let status: 'completed' | 'failed' | 'aborted' + try { + status = await loop.runTurn(thread.id, started.turnId) + } finally { + input.signal.removeEventListener('abort', abortChild) + } // Only a FATAL error fails the child. Recoverable tool errors — a tool // rejected by the child's read-only policy, or a tool that crashed — are // recorded as `severity: 'warning'` error events but the loop hands the diff --git a/kun/src/delegation/delegation-runtime.test.ts b/kun/src/delegation/delegation-runtime.test.ts new file mode 100644 index 000000000..65343d926 --- /dev/null +++ b/kun/src/delegation/delegation-runtime.test.ts @@ -0,0 +1,214 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { CapabilityRegistry } from '../adapters/tool/capability-registry.js' +import { LocalToolHost } from '../adapters/tool/local-tool-host.js' +import { InMemoryEventBus } from '../adapters/in-memory-event-bus.js' +import { InMemorySessionStore } from '../adapters/in-memory-session-store.js' +import { InMemoryThreadStore } from '../adapters/in-memory-thread-store.js' +import { createImmutablePrefix } from '../cache/immutable-prefix.js' +import { SubagentsCapabilityConfig } from '../contracts/capabilities.js' +import { emptyUsageSnapshot } from '../contracts/usage.js' +import { createThreadRecord } from '../domain/thread.js' +import { ContextCompactor } from '../loop/context-compactor.js' +import { InflightTracker } from '../loop/inflight-tracker.js' +import { SteeringQueue } from '../loop/steering-queue.js' +import type { ModelClient, ModelRequest, ModelStreamChunk } from '../ports/model-client.js' +import { SequentialIdGenerator } from '../ports/id-generator.js' +import { RuntimeEventRecorder } from '../services/runtime-event-recorder.js' +import { TurnService } from '../services/turn-service.js' +import { createChildAgentExecutor } from './child-agent-executor.js' +import { DelegationRuntime, FileDelegationStore } from './delegation-runtime.js' + +class HangingModel implements ModelClient { + readonly provider = 'test' + readonly model = 'test-model' + readonly requests: ModelRequest[] = [] + private resolveRequest: (() => void) | undefined + readonly requestStarted = new Promise((resolve) => { + this.resolveRequest = resolve + }) + + async *stream(request: ModelRequest): AsyncIterable { + this.requests.push(request) + this.resolveRequest?.() + await new Promise((resolve) => { + if (request.abortSignal.aborted) { + resolve() + return + } + request.abortSignal.addEventListener('abort', () => resolve(), { once: true }) + }) + if (!request.abortSignal.aborted) { + yield { kind: 'usage', usage: emptyUsageSnapshot() } + yield { kind: 'completed', stopReason: 'stop' } + } + } +} + +describe('DelegationRuntime abort handling', () => { + it('does not abort detached children when the parent signal aborts', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kun-delegation-')) + try { + let childSignal: AbortSignal | undefined + const runtime = new DelegationRuntime({ + config: subagentConfig(), + store: new FileDelegationStore(dir), + executor: async (input) => { + childSignal = input.signal + await new Promise((resolve) => { + input.signal.addEventListener('abort', () => resolve(), { once: true }) + }) + throw new Error('aborted') + } + }) + const parent = new AbortController() + const record = await runtime.runChild({ + parentThreadId: 'parent', + parentTurnId: 'turn', + prompt: 'background work', + detach: true, + signal: parent.signal + }) + + await waitFor(() => childSignal !== undefined) + parent.abort() + expect(childSignal?.aborted).toBe(false) + + expect(runtime.abortChild(record.id)).toBe(true) + await waitFor(() => childSignal?.aborted === true) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('wakes the parent thread when a detached child settles after the parent turn was interrupted', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kun-delegation-')) + try { + const { runtime, threadStore, turns } = makeRuntime(dir) + await threadStore.upsert(createThreadRecord({ + id: 'parent', + title: 'Parent', + workspace: '/ws', + model: 'test-model' + })) + const parentTurn = await turns.startTurn({ + threadId: 'parent', + request: { prompt: 'start parent' } + }) + await turns.interruptTurn({ + threadId: 'parent', + turnId: parentTurn.turnId + }) + const runTurn = vi.fn(async (_threadId: string, _turnId: string) => undefined) + runtime.bindAgentLoop({ runTurn }) + + await runtime.runChild({ + parentThreadId: 'parent', + parentTurnId: parentTurn.turnId, + label: 'research', + prompt: 'background work', + detach: true, + signal: new AbortController().signal + }) + + await waitFor(() => runTurn.mock.calls.length === 1) + expect(runTurn.mock.calls[0][0]).toBe('parent') + const thread = await threadStore.get('parent') + expect(thread?.status).toBe('running') + const resumedTurn = thread?.turns.at(-1) + expect(resumedTurn?.prompt).toContain('') + expect(resumedTurn?.prompt).toContain('') + expect(resumedTurn?.items?.[0]).toMatchObject({ + kind: 'user_message', + messageSource: 'background_subagent', + displayText: 'Background subagent research completed' + }) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe('createChildAgentExecutor abort handling', () => { + it('connects the parent signal to the child turn interrupt', async () => { + const model = new HangingModel() + const executor = createChildAgentExecutor({ + model, + toolHost: new LocalToolHost({ registry: new CapabilityRegistry() }), + prefix: createImmutablePrefix({ systemPrompt: 'You are Kun.' }), + defaultModel: 'test-model' + }) + const parent = new AbortController() + const run = executor({ + childId: 'child', + parentThreadId: 'parent', + parentTurnId: 'turn', + prompt: 'work until interrupted', + toolPolicy: 'inherit', + signal: parent.signal + }) + + await model.requestStarted + parent.abort() + + await expect(run).rejects.toThrow('Child agent aborted.') + expect(model.requests[0].abortSignal.aborted).toBe(true) + }) +}) + +function subagentConfig() { + return SubagentsCapabilityConfig.parse({ + enabled: true, + maxParallel: 1, + maxChildRuns: 10 + }) +} + +function makeRuntime(dir: string): { + runtime: DelegationRuntime + threadStore: InMemoryThreadStore + turns: TurnService +} { + const nowIso = () => '2026-07-04T00:00:00.000Z' + const threadStore = new InMemoryThreadStore() + const sessionStore = new InMemorySessionStore() + const eventBus = new InMemoryEventBus() + const events = new RuntimeEventRecorder({ + eventBus, + sessionStore, + allocateSeq: (threadId) => eventBus.allocateSeq(threadId), + nowIso + }) + const turns = new TurnService({ + threadStore, + sessionStore, + events, + inflight: new InflightTracker(), + steering: new SteeringQueue(), + compactor: new ContextCompactor(), + ids: new SequentialIdGenerator(), + nowIso + }) + const runtime = new DelegationRuntime({ + config: subagentConfig(), + store: new FileDelegationStore(dir), + events, + threadStore, + turns, + nowIso, + executor: async () => ({ + summary: 'done' + }) + }) + return { runtime, threadStore, turns } +} + +async function waitFor(predicate: () => boolean, timeoutMs = 1000): Promise { + const started = Date.now() + while (!predicate()) { + if (Date.now() - started > timeoutMs) throw new Error('waitFor timed out') + await new Promise((resolve) => setTimeout(resolve, 5)) + } +} diff --git a/kun/src/delegation/delegation-runtime.ts b/kun/src/delegation/delegation-runtime.ts index 734cb894d..185f19d6c 100644 --- a/kun/src/delegation/delegation-runtime.ts +++ b/kun/src/delegation/delegation-runtime.ts @@ -4,6 +4,8 @@ import { z } from 'zod' import { SubagentToolPolicy, type SubagentMode, type SubagentProfileConfig, type SubagentsCapabilityConfig } from '../contracts/capabilities.js' import type { RuntimeEventRecorder } from '../services/runtime-event-recorder.js' import type { UsageSnapshot } from '../contracts/usage.js' +import type { ThreadStore } from '../ports/thread-store.js' +import type { TurnService } from '../services/turn-service.js' import { loadWorkspaceAgentProfiles } from './workspace-agents.js' const ChildRunUsage = z.object({ @@ -41,6 +43,8 @@ export const ChildRunRecord = z.object({ profile: z.string().optional(), /** Effective tool policy applied to the child (read-only vs inherited). */ toolPolicy: SubagentToolPolicy.optional(), + /** True when this child is detached from the parent turn lifecycle. */ + detached: z.boolean().optional(), status: z.enum(['queued', 'running', 'completed', 'failed', 'aborted']), summary: z.string().optional(), evidence: z.array(z.string().min(1).max(2_000)).max(32).optional(), @@ -60,6 +64,8 @@ export const ChildRunRecord = z.object({ durationMs: z.number().int().nonnegative().optional(), /** Wall-clock spent waiting for a parallel slot before starting. */ queuedMs: z.number().int().nonnegative().optional(), + /** Stable display order for this child inside its parent turn. */ + childSeq: z.number().int().nonnegative().optional(), createdAt: z.string(), /** When the child left the queue and began running. */ startedAt: z.string().optional(), @@ -153,9 +159,12 @@ type SlotWaiter = { onAbort: () => void } +type RunTurnFn = (threadId: string, turnId: string) => Promise + export class DelegationRuntime { private active = 0 private childSeq = 0 + private readonly childSeqById = new Map() /** Children waiting for a parallel slot, in FIFO order. */ private readonly slotWaiters: SlotWaiter[] = [] /** Per-thread child counts (persisted + in-flight) for the budget cap. */ @@ -168,17 +177,24 @@ export class DelegationRuntime { * GUI even after the parent turn finished. */ private readonly detachedAborts = new Map() + private runTurn: RunTurnFn | null = null constructor(private options: { config: SubagentsCapabilityConfig store: FileDelegationStore events?: RuntimeEventRecorder + threadStore?: ThreadStore + turns?: TurnService nowIso?: () => string idGenerator?: () => string executor?: ChildRunExecutor recordExternalUsage?: (threadId: string, usage: UsageSnapshot) => void }) {} + bindAgentLoop(input: { runTurn: RunTurnFn }): void { + this.runTurn = input.runTurn + } + replaceConfig(config: SubagentsCapabilityConfig): void { this.options = { ...this.options, @@ -198,6 +214,8 @@ export class DelegationRuntime { workspace?: string model?: string providerId?: string + /** Parent turn/thread provider id inherited by delegate_task when no profile overrides it. */ + inheritedProviderId?: string profile?: string /** Forward GUI design-canvas scope into the child turn when present. */ guiDesignCanvas?: boolean @@ -241,7 +259,7 @@ export class DelegationRuntime { } const toolPolicy = profile?.toolPolicy ?? config.defaultToolPolicy const resolvedModel = input.model?.trim() || profile?.model - const resolvedProviderId = input.providerId?.trim() || profile?.providerId + const resolvedProviderId = input.providerId?.trim() || profile?.providerId || input.inheritedProviderId?.trim() const resolvedSystemPrompt = profile?.systemPrompt const resolvedAllowedTools = profile?.allowedTools const resolvedBlockedTools = profile?.blockedTools @@ -275,7 +293,9 @@ export class DelegationRuntime { tokenBudget, timeBudgetMs, returnFormat, + ...(input.detach ? { detached: true } : {}), status: 'queued', + childSeq: this.nextChildSeq(id), createdAt: queuedAt, updatedAt: queuedAt }) @@ -317,7 +337,10 @@ export class DelegationRuntime { parentTurnId: input.parentTurnId, prompt: input.prompt, signal: detachedController.signal - }).finally(() => this.detachedAborts.delete(record.id)) + }) + .then((settled) => this.notifyDetachedChild(settled)) + .catch(() => undefined) + .finally(() => this.detachedAborts.delete(record.id)) return record } @@ -696,7 +719,8 @@ export class DelegationRuntime { childId: record.id, childLabel: record.label, childStatus: record.status, - childSeq: ++this.childSeq, + childSeq: this.stableChildSeq(record), + ...(record.detached ? { detached: true } : {}), ...(record.model ? { childModel: record.model } : {}), ...(record.providerId ? { childProviderId: record.providerId } : {}), ...(record.profile ? { childProfile: record.profile } : {}), @@ -714,12 +738,60 @@ export class DelegationRuntime { }) } + private nextChildSeq(childId: string): number { + const existing = this.childSeqById.get(childId) + if (existing !== undefined) return existing + const next = ++this.childSeq + this.childSeqById.set(childId, next) + return next + } + + private stableChildSeq(record: ChildRunRecord): number { + if (record.childSeq !== undefined) { + this.childSeqById.set(record.id, record.childSeq) + this.childSeq = Math.max(this.childSeq, record.childSeq) + return record.childSeq + } + return this.nextChildSeq(record.id) + } + private recordExternalUsage(record: ChildRunRecord): void { const usage = toUsageSnapshot(record.usage) if (usage.totalTokens <= 0 && usage.costUsd === undefined && usage.costCny === undefined) return this.options.recordExternalUsage?.(record.parentThreadId, usage) } + private async notifyDetachedChild(record: ChildRunRecord): Promise { + if (record.status !== 'completed' && record.status !== 'failed') return + if (!this.options.threadStore || !this.options.turns || !this.runTurn) return + const thread = await this.options.threadStore.get(record.parentThreadId) + if (!thread) return + const notice = formatDetachedChildNotice(record) + const displayText = formatDetachedChildDisplayText(record) + if (thread.status === 'running') { + const runningTurn = [...thread.turns].reverse().find((turn) => turn.status === 'running') + if (runningTurn) { + await this.options.turns.steerTurn({ + threadId: record.parentThreadId, + turnId: runningTurn.id, + text: notice, + displayText, + messageSource: 'background_subagent' + }) + return + } + } + const started = await this.options.turns.startTurn({ + threadId: record.parentThreadId, + request: { + prompt: notice, + displayText, + messageSource: 'background_subagent' + } + }) + void this.runTurn(record.parentThreadId, started.turnId) + } + private now(): string { return this.options.nowIso?.() ?? new Date().toISOString() } @@ -846,6 +918,37 @@ function positiveInteger(value: number | undefined): number | undefined { return value } +function formatDetachedChildDisplayText(record: ChildRunRecord): string { + const label = record.label?.trim() || record.profile?.trim() || record.id + return `Background subagent ${label} ${record.status}` +} + +function formatDetachedChildNotice(record: ChildRunRecord): string { + const label = record.label?.trim() || record.profile?.trim() || record.id + const lines = [ + '', + `${escapeXml(record.id)}`, + ``, + `${record.status === 'failed' ? 'failed' : 'completed'}` + ] + if (record.summary?.trim()) { + lines.push(`${escapeXml(record.summary.trim())}`) + } + if (record.error?.trim()) { + lines.push(`${escapeXml(record.error.trim())}`) + } + lines.push('') + return lines.join('\n') +} + +function escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + const defaultExecutor: ChildRunExecutor = async (input) => { return { summary: `Child result: ${input.prompt}` } } diff --git a/kun/src/domain/item.ts b/kun/src/domain/item.ts index 3263d8813..06cb6a851 100644 --- a/kun/src/domain/item.ts +++ b/kun/src/domain/item.ts @@ -1,4 +1,4 @@ -import type { TurnItem } from '../contracts/items.js' +import type { TurnItem, UserMessageSource } from '../contracts/items.js' import type { ReviewOutput, ReviewTarget } from '../contracts/review.js' export type ItemEntity = TurnItem @@ -9,7 +9,7 @@ export function makeUserItem(input: { threadId: string text: string displayText?: string - messageSource?: 'background_shell' + messageSource?: UserMessageSource attachmentIds?: string[] fileReferences?: Array<{ path: string; relativePath: string; name: string; kind?: 'file' | 'directory' }> workspaceCheckpointId?: string diff --git a/kun/src/domain/turn.ts b/kun/src/domain/turn.ts index 49baeaac8..fcdcbff79 100644 --- a/kun/src/domain/turn.ts +++ b/kun/src/domain/turn.ts @@ -16,6 +16,7 @@ export function createTurnRecord(input: { guiDesignCanvas?: boolean mode?: ThreadMode disableUserInput?: boolean + imContext?: boolean workspaceCheckpointId?: string createdAt?: string status?: TurnStatus @@ -42,6 +43,7 @@ export function createTurnRecord(input: { ...(input.guiDesignCanvas ? { guiDesignCanvas: true } : {}), ...(input.mode ? { mode: input.mode } : {}), ...(input.disableUserInput ? { disableUserInput: true } : {}), + ...(input.imContext ? { imContext: true } : {}), ...(input.workspaceCheckpointId ? { workspaceCheckpointId: input.workspaceCheckpointId } : {}), createdAt: input.createdAt ?? new Date().toISOString() } diff --git a/kun/src/loop/agent-loop.ts b/kun/src/loop/agent-loop.ts index e7b8c14fd..a73373ad4 100644 --- a/kun/src/loop/agent-loop.ts +++ b/kun/src/loop/agent-loop.ts @@ -261,9 +261,8 @@ const PLAN_READ_ONLY_TOOL_NAMES = new Set([ /** Interactive tools allowed during the investigation phase (step 0) of a * Plan-mode turn so the model can ask the user a structured clarifying * question (with options) and continue to `create_plan` in the same turn - * instead of stopping with a prose question. Only effective on GUI turns: - * these tools advertise off `awaitUserInput`, so IM/headless plan turns never - * surface them and the prose-and-stop fallback applies there. */ + * instead of stopping with a prose question. IM/headless turns retain the + * stable catalog but receive an instruction not to call these tools. */ const PLAN_INTERACTIVE_TOOL_NAMES = new Set(['user_input', 'request_user_input']) /** @@ -617,16 +616,10 @@ function latestUserMessageText(items: readonly TurnItem[], turnId: string): stri return '' } -/** - * Injected when the turn runs without an interactive user (IM bridges, - * headless runs). The user-input tools are also withheld from the tool - * catalog; this line keeps the model from promising a GUI dialog that - * nobody can answer. - */ function userInputUnavailableInstruction(): string { return [ - 'Interactive user input is unavailable for this turn: the user is on a remote channel (IM) and cannot answer GUI prompts.', - 'Do not ask for structured input or wait for confirmation. If information is missing, state your assumption and continue, or finish your reply with the question so the user can answer in their next message.' + 'The `user_input` and `request_user_input` tools are unavailable for this turn because the user cannot answer GUI prompts.', + 'Do not call either tool. If information is missing, ask the question in your normal response and end the turn so the user can answer in their next message.' ].join(' ') } @@ -1443,9 +1436,9 @@ export class AgentLoop { ), this.opts.forcedAllowedToolNames ) - // IM/headless turns run without the user-input gate; the tools key - // their advertisement off `awaitUserInput`, so omitting it hides - // `user_input`/`request_user_input` and rejects stray calls. + // IM/headless turns run without the user-input gate. The tools stay + // advertised so GUI/IM transitions keep a stable provider tool + // catalog; execution returns a tool error if the model calls them. const userInputDisabled = turn?.disableUserInput === true const toolContext: ToolHostContext = { threadId, @@ -1454,6 +1447,7 @@ export class AgentLoop { threadMode: effectiveMode, ...(activePlanContext ? { guiPlan: activePlanContext } : {}), ...(turn?.guiDesignCanvas ? { guiDesignCanvas: true } : {}), + ...(turn?.imContext ? { imContext: true } : {}), model: modelCapabilities, activeSkillIds: skillResolution.activeSkillIds, memoryPolicy: { enabled: Boolean(this.opts.memoryStore) }, @@ -1754,6 +1748,17 @@ export class AgentLoop { break case 'tool_call_delta': break + case 'retrying': + await this.opts.events.record({ + kind: 'model_request_retry', + threadId, + turnId, + status: chunk.status, + attempt: chunk.attempt, + maxAttempts: chunk.maxAttempts, + delayMs: chunk.delayMs + }) + break case 'tool_call_complete': { const provider = toolProviderMetadata.get(chunk.toolName) const toolKind = toolKinds.get(chunk.toolName) @@ -1940,6 +1945,7 @@ export class AgentLoop { threadMode: effectiveMode, activePlanContext, guiDesignCanvas: turn?.guiDesignCanvas === true, + modelProviderId: providerId, modelCapabilities, activeSkillIds: skillResolution.activeSkillIds, allowedToolNames, @@ -2108,10 +2114,12 @@ export class AgentLoop { threadMode: effectiveMode, activePlanContext, guiDesignCanvas: turn?.guiDesignCanvas === true, + modelProviderId: providerId, modelCapabilities, activeSkillIds: skillResolution.activeSkillIds, allowedToolNames, userInputDisabled, + imContext: turn?.imContext === true, toolProviderKinds: new Map(tools.map((tool) => [tool.name, tool.providerKind])), approvalPolicy, sandboxMode, @@ -2130,10 +2138,12 @@ export class AgentLoop { threadMode?: 'agent' | 'plan' activePlanContext?: GuiPlanContext guiDesignCanvas?: boolean + modelProviderId?: string modelCapabilities: ModelCapabilityMetadata activeSkillIds: readonly string[] allowedToolNames?: readonly string[] userInputDisabled?: boolean + imContext?: boolean toolProviderKinds: ReadonlyMap approvalPolicy: ToolHostContext['approvalPolicy'] sandboxMode: NonNullable @@ -2272,10 +2282,12 @@ export class AgentLoop { threadMode?: 'agent' | 'plan' activePlanContext?: GuiPlanContext guiDesignCanvas?: boolean + modelProviderId?: string modelCapabilities: ModelCapabilityMetadata activeSkillIds: readonly string[] allowedToolNames?: readonly string[] userInputDisabled?: boolean + imContext?: boolean approvalPolicy: ToolHostContext['approvalPolicy'] sandboxMode: NonNullable signal: AbortSignal @@ -2287,7 +2299,9 @@ export class AgentLoop { threadMode: input.threadMode, ...(input.activePlanContext ? { guiPlan: input.activePlanContext } : {}), ...(input.guiDesignCanvas ? { guiDesignCanvas: true } : {}), + ...(input.imContext ? { imContext: true } : {}), model: input.modelCapabilities, + ...(input.modelProviderId ? { modelProviderId: input.modelProviderId } : {}), activeSkillIds: input.activeSkillIds, memoryPolicy: { enabled: Boolean(this.opts.memoryStore) }, delegationPolicy: { enabled: false }, diff --git a/kun/src/loop/compaction-summary.ts b/kun/src/loop/compaction-summary.ts index fe7a9992e..1beb1d5f4 100644 --- a/kun/src/loop/compaction-summary.ts +++ b/kun/src/loop/compaction-summary.ts @@ -20,7 +20,14 @@ export const DEFAULT_COMPACTION_SUMMARY_INPUT_MAX_BYTES = 96 * 1024 export const COMPACTION_SYSTEM_PROMPT = [ 'You are summarizing a long coding-agent conversation so the work can continue past the context window.', '', - 'Provide a detailed but concise summary. Focus on information that would be helpful for continuing the work, including:', + 'Write a structured handoff summary with these sections:', + '## Goal', + '## Durable Context', + '## Completed and Decisions', + '## Files, Commands, and Results', + '## Open Issues and Next Steps', + '', + 'Focus on information that would be helpful for continuing the work, including:', '- What was requested and the overall goal', '- What has been done and the decisions that were made (and why)', '- Which files are being created, edited, or inspected (with their paths)', @@ -28,6 +35,7 @@ export const COMPACTION_SYSTEM_PROMPT = [ '- What still needs to be done next', '- User requests, constraints, and preferences that must persist', '', + 'If the conversation contains a numbered or bulleted list of issues, tasks, TODOs, problems, requirements, or user findings, preserve every item that is still relevant. Do not collapse middle items into a range, "etc.", or an omitted-count line.', 'Preserve concrete identifiers verbatim: file paths, function and variable names, commands, URLs, IDs, and error messages.', 'Do not invent facts and do not add generic advice. Write in the same language as the conversation.', 'Your summary should be comprehensive enough to provide full context but concise enough to be quickly understood.' @@ -45,7 +53,8 @@ export function buildCompactionContinuationMessage(pinnedConstraints?: readonly 'Provide a detailed summary of our conversation above, written so a new session with no access to ' + 'this history can continue the work seamlessly. Cover what we set out to do, what has been done, ' + 'which files and locations are involved, the key findings and decisions, and what remains to be done next. ' + - 'Preserve concrete identifiers (file paths, function/variable names, commands, URLs, IDs, error messages) verbatim.' + 'Preserve concrete identifiers (file paths, function/variable names, commands, URLs, IDs, error messages) verbatim. ' + + 'If there are numbered or bulleted issue/task/problem/TODO/requirement lists, keep every still-relevant item rather than summarizing them as a range or omitted middle.' ] const pins = (pinnedConstraints ?? []).map((pin) => pin.trim()).filter((pin) => pin.length > 0) if (pins.length > 0) { diff --git a/kun/src/loop/context-compactor.test.ts b/kun/src/loop/context-compactor.test.ts new file mode 100644 index 000000000..b93b638b3 --- /dev/null +++ b/kun/src/loop/context-compactor.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { createImmutablePrefix } from '../cache/immutable-prefix.js' +import type { TurnItem } from '../contracts/items.js' +import { makeAssistantTextItem, makeUserItem } from '../domain/item.js' +import { ContextCompactor } from './context-compactor.js' + +describe('ContextCompactor', () => { + it('preserves numbered problem outlines when heuristic compaction is the fallback', () => { + const threadId = 'thr_compaction_outline' + const turnId = 'turn_compaction_outline' + const problems = Array.from( + { length: 80 }, + (_, index) => `Problem ${index + 1}: preserve finding ${index + 1}` + ) + const history: TurnItem[] = [ + makeUserItem({ + id: 'item_user_start', + threadId, + turnId, + text: 'Please keep the complete issue list when compacting.' + }), + makeAssistantTextItem({ + id: 'item_problem_list', + threadId, + turnId, + status: 'completed', + text: ['Current problem list:', ...problems].join('\n') + }), + ...Array.from({ length: 50 }, (_, index) => + makeAssistantTextItem({ + id: `item_filler_${index}`, + threadId, + turnId, + status: 'completed', + text: `Routine progress note ${index + 1}.` + }) + ), + makeUserItem({ + id: 'item_recent_tail', + threadId, + turnId, + text: 'Recent tail request kept verbatim.' + }) + ] + + const result = new ContextCompactor().compact({ + threadId, + turnId, + history, + prefix: createImmutablePrefix({ + pinnedConstraints: ['system: preserve user intent across compaction'] + }), + keepRecent: 1, + reason: 'test forced fallback summary' + }) + + expect(result.summaryItem.kind).toBe('compaction') + if (result.summaryItem.kind !== 'compaction') return + expect(result.summaryItem.summary).toContain('Problem 1: preserve finding 1') + expect(result.summaryItem.summary).toContain('Problem 42: preserve finding 42') + expect(result.summaryItem.summary).toContain('Problem 80: preserve finding 80') + expect(result.summaryItem.summary).not.toContain('middle item(s) omitted from this compact summary') + }) +}) diff --git a/kun/src/loop/context-compactor.ts b/kun/src/loop/context-compactor.ts index 050db0e23..3dfb7e092 100644 --- a/kun/src/loop/context-compactor.ts +++ b/kun/src/loop/context-compactor.ts @@ -347,10 +347,21 @@ function buildCompactionSummary(input: { lines.push( `Summarized ${input.history.length} item(s); ${input.tail.length} recent item(s) are also kept verbatim for the current request.` ) + const durableOutlineLines = fitLinesToBudget( + extractDurableOutlineLines(input.history), + Math.floor(contentBudget * 0.75) + ) + if (durableOutlineLines.length > 0) { + lines.push('Durable outline and open items:') + lines.push(...durableOutlineLines) + lines.push('') + } lines.push('Conversation and work summary:') + const usedBudget = lines.join('\n').length + const remainingBudget = Math.max(1_200, contentBudget - usedBudget) const summaryLines = fitLinesToBudget( selectSummaryLines(input.history.map(summarizeItem).filter((line) => line.length > 0)), - contentBudget + remainingBudget ) if (summaryLines.length === 0) { lines.push('- No user-visible content before compaction.') @@ -376,8 +387,102 @@ function extractSkillPins(history: TurnItem[]): string[] { } function summaryCharBudget(budgetTokens: number | undefined): number { - if (budgetTokens === undefined) return 4_000 - return Math.max(1_200, Math.min(12_000, budgetTokens * 4)) + if (budgetTokens === undefined) return 12_000 + return Math.max(1_200, Math.min(24_000, budgetTokens * 4)) +} + +function extractDurableOutlineLines(history: TurnItem[]): string[] { + const lines: string[] = [] + for (const item of history) { + switch (item.kind) { + case 'user_message': + lines.push(...durableTextLines('User request', item.text, { fallback: true })) + break + case 'assistant_text': + lines.push(...durableTextLines('Assistant finding', item.text)) + break + case 'compaction': + if (item.replacedTokens > 0) { + lines.push(...durableTextLines('Earlier compaction', item.summary)) + } + break + case 'tool_call': { + const text = item.summary || stringifyCompact(item.arguments) + if (isDurableTextLine(text)) { + lines.push(`- Tool call ${item.toolName}: ${clipText(text, 520)}`) + } + break + } + case 'tool_result': { + const text = stringifyCompact(item.output) + if (item.isError || isDurableTextLine(text)) { + lines.push(`- Tool result ${item.toolName}${item.isError ? ' error' : ''}: ${clipText(text, 520)}`) + } + break + } + case 'approval': + if (item.status !== 'allowed') { + lines.push(`- Approval ${item.status} for ${item.toolName}: ${clipText(item.summary, 520)}`) + } + break + case 'user_input': + lines.push(`- User input ${item.status}: ${clipText(item.prompt, 520)}`) + break + case 'review': + lines.push(...durableTextLines('Review', item.reviewText || stringifyCompact(item.output))) + break + case 'error': + lines.push(`- Error${item.code ? ` ${item.code}` : ''}: ${clipText(item.message, 520)}`) + break + case 'assistant_reasoning': + break + } + } + return dedupeLines(lines) +} + +function durableTextLines( + label: string, + text: string, + options?: { fallback?: boolean } +): string[] { + const rawLines = text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + const selected = rawLines.filter(isDurableTextLine) + if (selected.length === 0 && options?.fallback) { + const clipped = clipText(text, 520) + return clipped ? [`- ${label}: ${clipped}`] : [] + } + return selected.map((line) => `- ${label}: ${clipText(line, 520)}`) +} + +const DURABLE_OUTLINE_LINE = + /^(?:#{1,6}\s+|[-*+]\s+(?:\[[ xX-]\]\s*)?|\d{1,4}[.)]\s+|[A-Za-z][.)]\s+|(?:problems?|issues?|tasks?|todos?|bugs?|fixes?|steps?)\s*#?\d{0,4}\b)/i +const DURABLE_KEYWORD_LINE = + /\b(?:issue|bug|problem|task|todo|open|done|next|remaining|scope|constraint|requirement|decision|root cause|fix|blocked|error|exception|failed|failing|command|test|file|path|must|need|expected|actual)\b/i +const DURABLE_IDENTIFIER_LINE = + /(?:https?:\/\/|#[0-9]+\b|`[^`]+`|(?:^|[ ./])[\w.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|json|md|py|go|rs|java|c|cpp|h|hpp|css|scss|html|yml|yaml)\b|\/[\w./-]+)/ + +function isDurableTextLine(text: string): boolean { + const line = text.trim() + if (!line) return false + if (DURABLE_OUTLINE_LINE.test(line)) return true + if (DURABLE_KEYWORD_LINE.test(line)) return true + return DURABLE_IDENTIFIER_LINE.test(line) +} + +function dedupeLines(lines: string[]): string[] { + const seen = new Set() + const out: string[] = [] + for (const line of lines) { + const key = line.replace(/\s+/g, ' ').trim().toLowerCase() + if (!key || seen.has(key)) continue + seen.add(key) + out.push(line) + } + return out } function summarizeItem(item: TurnItem): string { @@ -408,14 +513,28 @@ function summarizeItem(item: TurnItem): string { } function selectSummaryLines(lines: string[]): string[] { - if (lines.length <= 20) return lines - const start = lines.slice(0, 4) - const end = lines.slice(-14) - return [ - ...start, - `- ${lines.length - start.length - end.length} middle item(s) omitted from this compact summary.`, - ...end - ] + if (lines.length <= 40) return lines + const start = lines.slice(0, 6) + const end = lines.slice(-18) + const middle = lines.slice(start.length, lines.length - end.length) + const criticalMiddle = middle.filter(isCriticalSummaryLine) + const selected = dedupeLines([...start, ...criticalMiddle, ...end]) + const omitted = lines.length - selected.length + if (omitted > 0) { + selected.splice( + Math.min(start.length + criticalMiddle.length, selected.length), + 0, + `- ${omitted} lower-priority transcript line(s) omitted after preserving detected user requests, task lists, errors, paths, and decisions.` + ) + } + return selected +} + +function isCriticalSummaryLine(line: string): boolean { + if (/^- User:/.test(line)) return true + if (/\b(?:error|failed|failing|exception|denied|cancelled)\b/i.test(line)) return true + const content = line.replace(/^- [^:]+:\s*/, '') + return isDurableTextLine(content) } function fitLinesToBudget(lines: string[], budget: number): string[] { diff --git a/kun/src/loop/steering-queue.ts b/kun/src/loop/steering-queue.ts index 928ba7626..fc4152747 100644 --- a/kun/src/loop/steering-queue.ts +++ b/kun/src/loop/steering-queue.ts @@ -1,3 +1,5 @@ +import type { UserMessageSource } from '../contracts/items.js' + /** * Mid-turn steering queue. The renderer posts steering text while a * turn is running; the queue collects those messages and injects them @@ -7,7 +9,7 @@ export type SteeringEntry = { text: string displayText?: string - messageSource?: 'background_shell' + messageSource?: UserMessageSource } export class SteeringQueue { diff --git a/kun/src/memory/memory-store.test.ts b/kun/src/memory/memory-store.test.ts new file mode 100644 index 000000000..2b56e6aaf --- /dev/null +++ b/kun/src/memory/memory-store.test.ts @@ -0,0 +1,51 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it } from 'vitest' +import { FileMemoryStore } from './memory-store.js' + +const tempDirs: string[] = [] + +async function makeTempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'kun-memory-store-')) + tempDirs.push(dir) + return dir +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +describe('FileMemoryStore', () => { + it('re-enables a disabled memory when updated with disabled false', async () => { + let tick = 0 + const store = new FileMemoryStore({ + rootDir: await makeTempDir(), + config: { enabled: true, scopes: ['workspace'], maxInjectedRecords: 8 }, + idGenerator: () => 'mem_toggle', + nowIso: () => `2026-06-21T00:00:0${tick++}.000Z` + }) + + await store.create({ + content: 'Prefer pnpm', + scope: 'workspace', + workspace: '/tmp/workspace' + }) + + const disabled = await store.update('mem_toggle', { disabled: true }, { workspace: '/tmp/workspace' }) + expect(disabled.disabledAt).toBe('2026-06-21T00:00:01.000Z') + await expect(store.retrieve({ + query: 'pnpm', + workspace: '/tmp/workspace', + limit: 8 + })).resolves.toEqual([]) + + const enabled = await store.update('mem_toggle', { disabled: false }, { workspace: '/tmp/workspace' }) + expect(enabled.disabledAt).toBeUndefined() + await expect(store.retrieve({ + query: 'pnpm', + workspace: '/tmp/workspace', + limit: 8 + })).resolves.toMatchObject([{ id: 'mem_toggle' }]) + }) +}) diff --git a/kun/src/ports/model-client.ts b/kun/src/ports/model-client.ts index 5264cf3e7..f12db795c 100644 --- a/kun/src/ports/model-client.ts +++ b/kun/src/ports/model-client.ts @@ -11,6 +11,7 @@ export type ModelStreamChunk = | { kind: 'assistant_reasoning_delta'; text: string } | { kind: 'tool_call_delta'; callId: string; toolName?: string; argumentsDelta?: string } | { kind: 'tool_call_complete'; callId: string; toolName: string; arguments: Record } + | { kind: 'retrying'; status: number; attempt: number; maxAttempts: number; delayMs: number } | { kind: 'image_generation_complete'; imageBase64: string; mimeType: string } | { kind: 'usage'; usage: UsageSnapshot } | { kind: 'completed'; stopReason: 'stop' | 'tool_calls' | 'length' | 'error' } diff --git a/kun/src/ports/session-store.ts b/kun/src/ports/session-store.ts index f734cc1a1..40a6db053 100644 --- a/kun/src/ports/session-store.ts +++ b/kun/src/ports/session-store.ts @@ -50,4 +50,6 @@ export interface SessionStore { loadLatestUsageSnapshots?(options?: { threadIds?: string[] }): Promise /** Forget the per-thread in-memory state without touching disk. */ resetMemory(): Promise + /** Forget cached state for a deleted thread without recreating its files. */ + clearThreadMemory(threadId: string): void } diff --git a/kun/src/ports/tool-host.ts b/kun/src/ports/tool-host.ts index 48f9d6690..3d412590b 100644 --- a/kun/src/ports/tool-host.ts +++ b/kun/src/ports/tool-host.ts @@ -66,8 +66,12 @@ export type ToolHostContext = { guiPlan?: GuiPlanContext /** True when the active GUI turn is allowed to mutate the design canvas. */ guiDesignCanvas?: boolean + /** True when the active turn originated from an IM bridge. */ + imContext?: boolean /** Active model capability metadata used by capability-aware providers. */ model?: ModelCapabilityMetadata + /** Active model provider id selected for this turn. Child agents inherit this routing unless a profile overrides it. */ + modelProviderId?: string /** Skill ids activated for this turn, if the Skill runtime is enabled. */ activeSkillIds?: readonly string[] /** Optional memory recall/mutation policy for this turn. */ diff --git a/kun/src/server/runtime-factory.ts b/kun/src/server/runtime-factory.ts index c00fe42a8..ecb45a3d5 100644 --- a/kun/src/server/runtime-factory.ts +++ b/kun/src/server/runtime-factory.ts @@ -59,6 +59,7 @@ import { type QualityConfig, type RolesConfig, type RuntimeTuningConfig, + type ModelRequestRetryConfig, type ServeProviderConfig, type StorageConfig, type ToolOutputLimitsConfig @@ -109,6 +110,7 @@ export type KunServeRuntimeOptions = { baseUrl: string modelProxyUrl?: string endpointFormat?: ModelEndpointFormat + retry?: ModelRequestRetryConfig /** * Extra HTTP headers merged into every default-client request (last, so * they win). For providers that need more than a Bearer key — e.g. Codex @@ -191,7 +193,18 @@ export async function createKunServeRuntime( 'system: keep the stable Kun prefix byte-stable for prompt-cache reuse' ] }) - const threadService = new ThreadService({ threadStore, sessionStore, events, ids, nowIso }) + const threadService = new ThreadService({ + threadStore, + sessionStore, + events, + ids, + nowIso, + onDeleted: (threadId) => { + usageService.reset(threadId) + events.clearThread(threadId) + eventBus.clearThread(threadId) + } + }) const artifactStore = new FileArtifactStore(join(activeOptions.dataDir, 'artifacts'), nowIso) let modelProfiles = modelContextProfilesFromConfig({ contextCompaction: activeOptions.contextCompaction, @@ -366,6 +379,8 @@ export async function createKunServeRuntime( config: mergeBuiltinSubagentProfiles(activeOptions.capabilities.subagents), store: new FileDelegationStore(join(activeOptions.dataDir, 'child-runs')), events, + threadStore, + turns: turnService, nowIso, executor: createChildAgentExecutor({ model: modelClient, @@ -581,6 +596,9 @@ export async function createKunServeRuntime( backgroundShellRuntime.bindAgentLoop({ runTurn: (threadId, turnId) => loop.runTurn(threadId, turnId) }) + delegationRuntime?.bindAgentLoop({ + runTurn: (threadId, turnId) => loop.runTurn(threadId, turnId) + }) const startedAt = activeOptions.startedAt ?? nowIso() const rebuildCapabilities = (): typeof capabilities => buildRuntimeCapabilityManifest({ config: activeOptions.capabilities, @@ -978,6 +996,7 @@ function buildModelClientRouterInput( apiKey: options.apiKey, modelProxyUrl: options.modelProxyUrl, endpointFormat: options.endpointFormat ?? DEFAULT_MODEL_ENDPOINT_FORMAT, + retry: options.retry, model: options.model, modelCapabilities, headers: options.headers, @@ -995,6 +1014,7 @@ function buildModelClientRouterInput( apiKey: provider.apiKey, modelProxyUrl: provider.modelProxyUrl ?? options.modelProxyUrl, endpointFormat: provider.endpointFormat ?? options.endpointFormat ?? DEFAULT_MODEL_ENDPOINT_FORMAT, + retry: provider.retry ?? options.retry, model: options.model, modelCapabilities, headers: provider.headers, @@ -1030,6 +1050,7 @@ function mergeRuntimeConfigApplyOptions( baseUrl: serve.baseUrl ?? current.baseUrl, modelProxyUrl: serve.modelProxyUrl ?? current.modelProxyUrl, endpointFormat: serve.endpointFormat ?? current.endpointFormat, + retry: serve.retry ?? current.retry, headers: serve.headers ?? current.headers, providers: serve.providers ?? current.providers, model: serve.model ?? current.model, diff --git a/kun/src/services/background-shell-output.ts b/kun/src/services/background-shell-output.ts index 66fd6a0cd..f22f983f5 100644 --- a/kun/src/services/background-shell-output.ts +++ b/kun/src/services/background-shell-output.ts @@ -128,6 +128,15 @@ export class BackgroundShellOutputWriter { async buildReturnFields( maxChars = DEFAULT_BACKGROUND_SHELL_OUTPUT_SUMMARY_MAX_CHARS ): Promise { + const stream = this.stream + if (stream) { + await new Promise((resolvePromise, reject) => { + stream.write('', (error) => { + if (error) reject(error) + else resolvePromise() + }) + }) + } const summary = await readBackgroundShellOutputSummary(this.paths.outputFilePath, maxChars) return { ...summary, diff --git a/kun/src/services/review-service.ts b/kun/src/services/review-service.ts index e808f21e8..0cfe9e411 100644 --- a/kun/src/services/review-service.ts +++ b/kun/src/services/review-service.ts @@ -210,7 +210,7 @@ export class ReviewService { request: { prompt: input.prompt, model: input.model, - providerId: input.providerId, + ...(input.providerId?.trim() ? { providerId: input.providerId.trim() } : {}), mode: 'agent', reasoningEffort: normalizeRoleReasoningEffort(this.deps.reasoningEffort) } diff --git a/kun/src/services/runtime-event-recorder.ts b/kun/src/services/runtime-event-recorder.ts index 926ae255b..d539cc1d8 100644 --- a/kun/src/services/runtime-event-recorder.ts +++ b/kun/src/services/runtime-event-recorder.ts @@ -78,4 +78,8 @@ export class RuntimeEventRecorder { const current = this.lastIssuedSeq.get(threadId) ?? 0 if (seq > current) this.lastIssuedSeq.set(threadId, seq) } + + clearThread(threadId: string): void { + this.lastIssuedSeq.delete(threadId) + } } diff --git a/kun/src/services/thread-service.ts b/kun/src/services/thread-service.ts index bf0d124c0..45b6adf8f 100644 --- a/kun/src/services/thread-service.ts +++ b/kun/src/services/thread-service.ts @@ -43,6 +43,7 @@ export type ThreadServiceOptions = { events: RuntimeEventRecorder ids: IdGenerator nowIso: () => string + onDeleted?: (threadId: string) => void } export type ListThreadsOptions = ThreadStoreListOptions @@ -78,6 +79,7 @@ export class ThreadService { private readonly events: RuntimeEventRecorder private readonly ids: IdGenerator private readonly nowIso: () => string + private readonly onDeleted?: (threadId: string) => void constructor(options: ThreadServiceOptions) { this.threadStore = options.threadStore @@ -85,6 +87,7 @@ export class ThreadService { this.events = options.events this.ids = options.ids this.nowIso = options.nowIso + this.onDeleted = options.onDeleted } async list(options: ListThreadsOptions = {}): Promise { @@ -377,6 +380,8 @@ export class ThreadService { async delete(threadId: string): Promise { const ok = await this.threadStore.delete(threadId) if (!ok) return false + this.sessionStore.clearThreadMemory(threadId) + this.onDeleted?.(threadId) return true } diff --git a/kun/src/services/turn-service.ts b/kun/src/services/turn-service.ts index c54e1316a..5b4461af9 100644 --- a/kun/src/services/turn-service.ts +++ b/kun/src/services/turn-service.ts @@ -8,7 +8,7 @@ import type { Turn, TurnStatus } from '../contracts/turns.js' -import type { TurnItem } from '../contracts/items.js' +import type { TurnItem, UserMessageSource } from '../contracts/items.js' import type { RuntimeErrorSeverity } from '../contracts/errors.js' import type { SessionStore } from '../ports/session-store.js' import type { ThreadStore } from '../ports/thread-store.js' @@ -89,6 +89,7 @@ export class TurnService { guiDesignCanvas: input.request.guiDesignCanvas, mode: input.request.mode, disableUserInput: input.request.disableUserInput, + imContext: input.request.imContext, workspaceCheckpointId: input.request.workspaceCheckpointId }) const userItem = makeUserItem({ @@ -171,7 +172,7 @@ export class TurnService { turnId: string text: string displayText?: string - messageSource?: 'background_shell' + messageSource?: UserMessageSource }): Promise { this.deps.steering.enqueue(input.turnId, { text: input.text, diff --git a/kun/tests/builtin-tools.test.ts b/kun/tests/builtin-tools.test.ts index e361d008a..2a67cb07a 100644 --- a/kun/tests/builtin-tools.test.ts +++ b/kun/tests/builtin-tools.test.ts @@ -1,7 +1,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { LocalToolHost, defaultLocalTools } from '../src/adapters/tool/local-tool-host.js' import { allBuiltinToolNames, @@ -337,11 +337,11 @@ describe('Kun built-in tools', () => { }) }) - it('hides GUI input tools when the turn context has no user-input gate', async () => { + it('keeps GUI input tools in the stable catalog without a user-input gate', async () => { const tools = await host.listTools(buildContext(workspace)) const names = tools.map((tool) => tool.name) - expect(names).not.toContain('user_input') - expect(names).not.toContain('request_user_input') + expect(names).toContain('user_input') + expect(names).toContain('request_user_input') }) it('exposes pi-style coding and read-only tool groups', () => { @@ -366,6 +366,7 @@ describe('Kun built-in tools', () => { 'lsp', 'read', 'repo_map', + 'send_im_attachment', 'verify_changes', 'write' ]) @@ -399,6 +400,7 @@ describe('Kun built-in tools', () => { 'lsp', 'read', 'repo_map', + 'send_im_attachment', 'verify_changes', 'write' ]) @@ -411,6 +413,7 @@ describe('Kun built-in tools', () => { 'lsp', 'read', 'repo_map', + 'send_im_attachment', 'verify_changes', 'write' ]) @@ -514,7 +517,9 @@ describe('Kun built-in tools', () => { expect(String(output.output)).toContain('hello local bash') }) - it('prefers the fd backend path when an fd executable candidate is provided', async () => { + it.skipIf(process.platform === 'win32')( + 'prefers the fd backend path when a POSIX executable candidate is provided', + async () => { await mkdir(join(workspace, 'notes'), { recursive: true }) await writeFile(join(workspace, 'notes', 'demo.txt'), 'demo\n', 'utf8') const fdHost = new LocalToolHost({ @@ -531,7 +536,8 @@ describe('Kun built-in tools', () => { }) expect(output.backend).toBe('fd') expect(output.matches).toHaveLength(1) - }) + } + ) it('writes, reads, edits, and searches workspace files', async () => { const writeOutput = await executeTool(host, workspace, 'write', { @@ -603,7 +609,9 @@ describe('Kun built-in tools', () => { expect(output.truncation).toBe(null) }) - it('finishes bash commands after the shell exits even when a background child keeps stdio open', async () => { + it.skipIf(process.platform === 'win32')( + 'finishes POSIX shell commands after a background child keeps stdio open', + async () => { const startedAt = Date.now() const output = await executeTool(host, workspace, 'bash', { command: 'sleep 5 & echo done', @@ -613,7 +621,8 @@ describe('Kun built-in tools', () => { expect(output.exit_code).toBe(0) expect(String(output.output)).toContain('done') expect(Date.now() - startedAt).toBeLessThan(1500) - }) + } + ) it('blocks foreground bash commands until the process exits', async () => { const startedAt = Date.now() @@ -629,7 +638,7 @@ describe('Kun built-in tools', () => { expect(Date.now() - startedAt).toBeGreaterThanOrEqual(1800) }) - it('returns immediately for background bash sessions and keeps running after abort', async () => { + it('returns a running background bash session and keeps running after abort', async () => { const hooks = { started: [] as string[], settled: [] as string[] @@ -651,7 +660,6 @@ describe('Kun built-in tools', () => { ] }) const abortController = new AbortController() - const startedAt = Date.now() const output = await backgroundHost.execute( { callId: 'call_bash_background', @@ -672,11 +680,9 @@ describe('Kun built-in tools', () => { expect(String(payload.session_id)).toMatch(/^[a-z0-9]{8}$/) expect(typeof payload.output_file).toBe('string') expect(String(payload.output_file)).toMatch(/\.output$/) - expect(Date.now() - startedAt).toBeLessThan(500) expect(hooks.started).toHaveLength(1) abortController.abort() - await new Promise((resolve) => setTimeout(resolve, 2500)) const read = await backgroundHost.execute( { callId: 'call_bash_background_read', @@ -704,7 +710,9 @@ describe('Kun built-in tools', () => { }, buildContext(workspace) ) - expect(hooks.settled.length).toBeGreaterThanOrEqual(1) + await vi.waitFor(() => { + expect(hooks.settled.length).toBeGreaterThanOrEqual(1) + }) }) it('polls completed background shell sessions via background_shell', async () => { @@ -761,14 +769,6 @@ describe('Kun built-in tools', () => { }) ] }) - await backgroundHost.execute( - { - callId: 'call_bash_bg', - toolName: 'bash', - arguments: { command: 'echo hi', background: true, timeout: 10 } - }, - buildContext(workspace) - ) const listed = await executeTool(backgroundHost, workspace, 'background_shell', { action: 'list', thread_only: false @@ -799,9 +799,14 @@ describe('Kun built-in tools', () => { const outputFile = String(payload.output_file) expect(outputFile).toContain('background-shells') expect(outputFile.endsWith(`${String(payload.session_id)}.output`)).toBe(true) - await new Promise((resolve) => setTimeout(resolve, 500)) + const completed = await executeTool(backgroundHost, workspace, 'background_shell', { + action: 'poll', + session_id: String(payload.session_id), + yield_seconds: 2 + }) + expect(completed.status).toBe('completed') const full = await readFile(outputFile, 'utf-8') - expect(full.startsWith('line-one\n')).toBe(true) + expect(full.replace(/\r\n/g, '\n').startsWith('line-one\n')).toBe(true) expect([...full].length).toBeGreaterThan(10_000) const read = await executeTool(backgroundHost, workspace, 'background_shell', { action: 'read', @@ -1072,9 +1077,14 @@ describe('Kun built-in tools', () => { const target = join(workspace, 'serial.txt') const order: string[] = [] + let markFirstStarted!: () => void + const firstStarted = new Promise((resolve) => { + markFirstStarted = resolve + }) let releaseFirst!: () => void const first = withFileMutationQueue(target, async () => { order.push('first:start') + markFirstStarted() await new Promise((resolve) => { releaseFirst = resolve }) @@ -1086,7 +1096,7 @@ describe('Kun built-in tools', () => { order.push('second:end') }) - await new Promise((resolve) => setTimeout(resolve, 20)) + await firstStarted expect(order).toEqual(['first:start']) releaseFirst() await Promise.all([first, second]) diff --git a/kun/tests/contracts.test.ts b/kun/tests/contracts.test.ts index f30129f9e..825e682af 100644 --- a/kun/tests/contracts.test.ts +++ b/kun/tests/contracts.test.ts @@ -683,7 +683,12 @@ describe('cli', () => { await writeFile(join(dataDir, 'config.json'), JSON.stringify({ serve: { baseUrl: 'https://example.invalid/v1', - model: 'deepseek-v4-flash' + model: 'deepseek-v4-flash', + retry: { + maxAttempts: 3, + initialDelayMs: 1000, + httpStatusCodes: [429] + } }, contextCompaction: { defaultSoftThreshold: 12_345, @@ -697,6 +702,11 @@ describe('cli', () => { expect(parsed.dataDir).toBe(dataDir) expect(parsed.baseUrl).toBe('https://example.invalid/v1') expect(parsed.model).toBe('deepseek-v4-flash') + expect(parsed.retry).toEqual({ + maxAttempts: 3, + initialDelayMs: 1000, + httpStatusCodes: [429] + }) expect(parsed.approvalPolicy).toBe(DEFAULT_APPROVAL_POLICY) expect(parsed.contextCompaction?.defaultHardThreshold).toBe(23_456) } finally { diff --git a/kun/tests/create-plan-tool.test.ts b/kun/tests/create-plan-tool.test.ts index 566a2e293..a08d46639 100644 --- a/kun/tests/create-plan-tool.test.ts +++ b/kun/tests/create-plan-tool.test.ts @@ -100,12 +100,21 @@ describe('create_plan tool: advertisement', () => { expect(names).not.toEqual(expect.arrayContaining(['bash', 'edit', 'write', 'echo'])) }) - it('drops the GUI input tools from plan-mode advertisement when no user-input gate is wired', async () => { + it('keeps the plan-mode tool catalog stable when no user-input gate is wired', async () => { const host = new LocalToolHost({ tools: buildDefaultLocalTools() }) const tools = await host.listTools(buildContext({ threadMode: 'plan' })) const names = tools.map((tool) => tool.name) - expect(names).toEqual(['read', 'grep', 'find', 'ls', 'repo_map', CREATE_PLAN_TOOL_NAME]) + expect(names).toEqual([ + 'read', + 'grep', + 'find', + 'ls', + 'repo_map', + 'user_input', + 'request_user_input', + CREATE_PLAN_TOOL_NAME + ]) }) it('keeps the normal agent-mode default tool advertisement unchanged', async () => { diff --git a/kun/tests/delegation-runtime.test.ts b/kun/tests/delegation-runtime.test.ts index 43f3bd3da..c2fe331ee 100644 --- a/kun/tests/delegation-runtime.test.ts +++ b/kun/tests/delegation-runtime.test.ts @@ -183,6 +183,69 @@ describe('DelegationRuntime', () => { } }) + it('inherits the parent model providerId through delegate_task', async () => { + const seen: Array = [] + const runtime = createRuntime({ + executor: async (input) => { + seen.push(input.providerId) + return { summary: 'done' } + } + }) + const host = new LocalToolHost({ + registry: new CapabilityRegistry(buildDelegationToolProviders(runtime)) + }) + + const result = await host.execute({ + callId: 'call_provider', + toolName: 'delegate_task', + arguments: { label: 'Provider', prompt: 'Check routing' } + }, { + threadId: 'thr_provider', + turnId: 'turn_provider', + workspace: '/tmp/ws', + modelProviderId: 'opencode-go', + approvalPolicy: 'auto', + abortSignal: new AbortController().signal, + awaitApproval: async () => 'allow' + }) + + expect(result.item).toMatchObject({ kind: 'tool_result', isError: false }) + expect(seen).toEqual(['opencode-go']) + expect((await runtime.diagnostics('thr_provider')).childRuns[0]?.providerId).toBe('opencode-go') + }) + + it('keeps a subagent profile providerId ahead of the inherited parent provider', async () => { + const seen: Array = [] + const runtime = createRuntime({ + defaultProfile: 'reviewer', + profiles: { reviewer: { providerId: 'profile-provider', toolPolicy: 'readOnly' } }, + executor: async (input) => { + seen.push(input.providerId) + return { summary: 'done' } + } + }) + const host = new LocalToolHost({ + registry: new CapabilityRegistry(buildDelegationToolProviders(runtime)) + }) + + await host.execute({ + callId: 'call_profile_provider', + toolName: 'delegate_task', + arguments: { label: 'Profile', prompt: 'Check profile routing' } + }, { + threadId: 'thr_profile_provider', + turnId: 'turn_profile_provider', + workspace: '/tmp/ws', + modelProviderId: 'opencode-go', + approvalPolicy: 'auto', + abortSignal: new AbortController().signal, + awaitApproval: async () => 'allow' + }) + + expect(seen).toEqual(['profile-provider']) + expect((await runtime.diagnostics('thr_profile_provider')).childRuns[0]?.providerId).toBe('profile-provider') + }) + it('forwards guiDesignCanvas from delegate_task context into the child run', async () => { const seen: boolean[] = [] const runtime = createRuntime({ diff --git a/kun/tests/file-session-store.test.ts b/kun/tests/file-session-store.test.ts index d132f535c..908915c54 100644 --- a/kun/tests/file-session-store.test.ts +++ b/kun/tests/file-session-store.test.ts @@ -107,4 +107,24 @@ describe('FileSessionStore', () => { expect(items.map((entry) => entry.id)).toEqual(['a', 'c', 'b']) expect(items.find((entry) => entry.id === 'b')).toMatchObject({ text: 'B-updated' }) }) + + it('forgets cached items for a deleted thread', async () => { + const sessionStore = new FileSessionStore({ dataDir }) + await sessionStore.appendItem('thr_deleted', { + id: 'item_1', + kind: 'assistant_text', + turnId: 'turn_1', + threadId: 'thr_deleted', + role: 'assistant', + status: 'completed', + createdAt: '2026-01-01T00:00:00.000Z', + text: 'cached' + }) + expect(await sessionStore.loadItems('thr_deleted')).toHaveLength(1) + await rm(join(dataDir, 'threads', 'thr_deleted'), { recursive: true, force: true }) + + sessionStore.clearThreadMemory('thr_deleted') + + expect(await sessionStore.loadItems('thr_deleted')).toEqual([]) + }) }) diff --git a/kun/tests/image-gen-tool-provider.test.ts b/kun/tests/image-gen-tool-provider.test.ts index d138dbff8..a7729306b 100644 --- a/kun/tests/image-gen-tool-provider.test.ts +++ b/kun/tests/image-gen-tool-provider.test.ts @@ -291,6 +291,50 @@ describe('Image gen tool provider', () => { }) }) + it('posts Codex subscription image edits with input images and edit action', async () => { + const requests: Array<{ body: string }> = [] + const resultBase64 = png(8, 8).toString('base64') + vi.stubGlobal('fetch', vi.fn(async (_url: string | URL, init?: RequestInit) => { + requests.push({ body: String(init?.body) }) + return new Response([ + `data: ${JSON.stringify({ + type: 'response.output_item.done', + item: { type: 'image_generation_call', result: resultBase64 } + })}`, + 'data: [DONE]' + ].join('\n\n'), { + status: 200, + headers: { 'content-type': 'text/event-stream' } + }) + })) + const client = new CodexResponsesImageClient('https://chatgpt.com/backend-api/codex', 'codex-access') + + const image = await client.edit({ + prompt: 'put basketball shoes on the character', + model: 'gpt-image-2', + images: [{ name: 'annotated.png', mimeType: 'image/png', data: png(16, 16) }], + timeoutMs: 1_000, + signal: new AbortController().signal + }) + + expect(image).toMatchObject({ mimeType: 'image/png' }) + expect(requests).toHaveLength(1) + const body = JSON.parse(requests[0].body) + expect(body.input[0].content).toEqual([ + { type: 'input_text', text: 'put basketball shoes on the character' }, + { + type: 'input_image', + image_url: expect.stringMatching(/^data:image\/png;base64,/), + detail: 'auto' + } + ]) + expect(body.tools[0]).toMatchObject({ + type: 'image_generation', + action: 'edit', + model: 'gpt-image-2' + }) + }) + it('uses the latest Codex partial image when the final image item is absent', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response([ `data: ${JSON.stringify({ @@ -607,9 +651,9 @@ describe('Image gen tool provider', () => { it('allowlists only real-edit protocols in protocolSupportsImageEdit', () => { expect(protocolSupportsImageEdit('openai-images')).toBe(true) + expect(protocolSupportsImageEdit('codex-responses-image')).toBe(true) expect(protocolSupportsImageEdit(undefined)).toBe(true) expect(protocolSupportsImageEdit('minimax-image')).toBe(false) - expect(protocolSupportsImageEdit('codex-responses-image')).toBe(false) }) it('returns edits_unsupported BEFORE any network call when references are passed on a non-edit protocol (MiniMax)', async () => { @@ -646,6 +690,15 @@ describe('Image gen tool provider', () => { const openaiTool = openaiTools.find((tool) => tool.name === 'generate_image')! expect(openaiTool.description).toContain('image-to-image') expect((openaiTool.inputSchema.properties as Record)).toHaveProperty('reference_image_paths') + + const codexTools = await new LocalToolHost({ + registry: new CapabilityRegistry( + buildImageGenToolProviders(imageGenConfig({ protocol: 'codex-responses-image' })).providers + ) + }).listTools(buildContext()) + const codexTool = codexTools.find((tool) => tool.name === 'generate_image')! + expect(codexTool.description).toContain('image-to-image') + expect((codexTool.inputSchema.properties as Record)).toHaveProperty('reference_image_paths') }) it('rejects reference paths that escape the workspace or are not images', async () => { @@ -704,7 +757,7 @@ describe('Image gen tool provider', () => { expect(result.item.output).toMatchObject({ error: { code: 'edits_unsupported', - message: expect.stringContaining('retry generate_image without reference_image_paths') + message: expect.stringContaining('reference image edits; retry generate_image without reference_image_paths') } }) } diff --git a/kun/tests/loop.test.ts b/kun/tests/loop.test.ts index d73293b51..7bc9678b5 100644 --- a/kun/tests/loop.test.ts +++ b/kun/tests/loop.test.ts @@ -508,6 +508,13 @@ describe('AgentLoop', () => { it('keeps running past the legacy eight-step ceiling until the model stops', async () => { let calls = 0 + const noop = LocalToolHost.defineTool({ + name: 'noop', + description: 'Complete without side effects.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + policy: 'auto', + execute: async () => ({ output: { ok: true } }) + }) const h = makeHarness( { provider: 'long-runner', @@ -517,9 +524,9 @@ describe('AgentLoop', () => { if (calls <= 9) { yield { kind: 'tool_call_complete', - callId: `call_ls_${calls}`, - toolName: 'ls', - arguments: { path: '.' } + callId: `call_noop_${calls}`, + toolName: 'noop', + arguments: {} } yield { kind: 'completed', stopReason: 'tool_calls' } return @@ -528,7 +535,7 @@ describe('AgentLoop', () => { yield { kind: 'completed', stopReason: 'stop' } } }, - { tools: buildDefaultLocalTools(), toolStorm: { enabled: false } } + { tools: [noop], toolStorm: { enabled: false } } ) await bootstrapThread(h) diff --git a/kun/tests/model-client.test.ts b/kun/tests/model-client.test.ts index cc664c6df..e00527cb7 100644 --- a/kun/tests/model-client.test.ts +++ b/kun/tests/model-client.test.ts @@ -2163,10 +2163,11 @@ describe('CompatModelClient', () => { expect(chunks[0].kind).toBe('error') expect(chunks[0]).toMatchObject({ kind: 'error', - message: `model request failed with status 400: ${body}`, + message: expect.stringContaining('model request failed with status 400: {"error":{"code":"400","message":"Not supported model mimo-v2.5-pro-ultraspeed'), code: 'http_400' }) - expect(JSON.stringify(chunks[0])).toContain(providerMessage) + expect(JSON.stringify(chunks[0])).toContain('...') + expect(JSON.stringify(chunks[0])).not.toContain(providerMessage) }) it('adds a proxy hint when a proxied model request fails before receiving a response', async () => { @@ -2430,6 +2431,141 @@ describe('CompatModelClient', () => { expect(usage && usage.kind === 'usage' ? usage.usage.totalTokens : 0).toBe(7) }) + it('retries configured HTTP statuses before streaming starts', async () => { + const statuses = [429, 200] + const fetchImpl: typeof fetch = async () => { + const status = statuses.shift() ?? 200 + if (status !== 200) return new Response('rate limited', { status }) + return new Response( + 'data: {"choices":[{"delta":{"content":"retried"}}]}\n\ndata: [DONE]\n\n', + { status: 200, headers: { 'content-type': 'text/event-stream' } } + ) + } + const client = new CompatModelClient({ + baseUrl: 'https://example.com/beta', + apiKey: 'k', + model: 'deepseek-chat', + fetchImpl, + retry: { + maxAttempts: 1, + initialDelayMs: 0, + httpStatusCodes: [429] + } + }) + const chunks = [] + for await (const chunk of client.stream(buildRequest(new AbortController().signal))) { + chunks.push(chunk) + } + + const text = chunks + .filter((c) => c.kind === 'assistant_text_delta') + .map((c) => (c as { text: string }).text) + .join('') + expect(text).toBe('retried') + expect(statuses).toHaveLength(0) + }) + + it('uses Retry-After before retrying configured HTTP statuses', async () => { + vi.useFakeTimers() + try { + const fetchImpl = vi.fn(async () => { + if (fetchImpl.mock.calls.length === 1) { + return new Response('rate limited', { status: 429, headers: { 'retry-after': '2' } }) + } + return new Response('{"choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop","index":0}]}', { + status: 200, + headers: { 'content-type': 'application/json' } + }) + }) + const client = new CompatModelClient({ + baseUrl: 'https://example.com/beta', + apiKey: 'k', + model: 'deepseek-chat', + fetchImpl, + nonStreaming: true, + retry: { + maxAttempts: 1, + initialDelayMs: 0, + httpStatusCodes: [429] + } + }) + const chunksPromise = (async () => { + const chunks = [] + for await (const chunk of client.stream(buildRequest(new AbortController().signal))) { + chunks.push(chunk) + } + return chunks + })() + + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1)) + await vi.advanceTimersByTimeAsync(1_999) + expect(fetchImpl).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + const chunks = await chunksPromise + + expect(fetchImpl).toHaveBeenCalledTimes(2) + expect(chunks.some((chunk) => chunk.kind === 'assistant_text_delta')).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('uses exponential backoff when Retry-After is absent', async () => { + vi.useFakeTimers() + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.5) + try { + const fetchImpl = vi.fn(async () => { + if (fetchImpl.mock.calls.length <= 2) { + return new Response('rate limited', { status: 429 }) + } + return new Response('{"choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop","index":0}]}', { + status: 200, + headers: { 'content-type': 'application/json' } + }) + }) + const client = new CompatModelClient({ + baseUrl: 'https://example.com/beta', + apiKey: 'k', + model: 'deepseek-chat', + fetchImpl, + nonStreaming: true, + retry: { + maxAttempts: 2, + initialDelayMs: 3000, + httpStatusCodes: [429] + } + }) + const chunksPromise = (async () => { + const chunks: ModelStreamChunk[] = [] + for await (const chunk of client.stream(buildRequest(new AbortController().signal))) { + chunks.push(chunk) + } + return chunks + })() + + await vi.advanceTimersByTimeAsync(0) + expect(fetchImpl).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(2999) + expect(fetchImpl).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(fetchImpl).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(5999) + expect(fetchImpl).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + const chunks = await chunksPromise + + expect(fetchImpl).toHaveBeenCalledTimes(3) + expect(chunks.filter((chunk) => chunk.kind === 'retrying')).toEqual([ + { kind: 'retrying', status: 429, attempt: 1, maxAttempts: 2, delayMs: 3000 }, + { kind: 'retrying', status: 429, attempt: 2, maxAttempts: 2, delayMs: 6000 } + ]) + expect(chunks.some((chunk) => chunk.kind === 'assistant_text_delta')).toBe(true) + } finally { + randomSpy.mockRestore() + vi.useRealTimers() + } + }) + it('merges streamed tool-call deltas by index when the provider id arrives later', async () => { const frames = [ 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"echo","arguments":"{\\"text\\":"}}]}}]}\n\n', diff --git a/kun/tests/ports.test.ts b/kun/tests/ports.test.ts index 83366cfa6..34033c436 100644 --- a/kun/tests/ports.test.ts +++ b/kun/tests/ports.test.ts @@ -27,6 +27,20 @@ describe('InMemoryEventBus', () => { bus.publish({ kind: 'heartbeat', seq: 2, timestamp: 't', threadId: 'a' }) expect(received).toEqual([1]) }) + + it('clears retained state for only the deleted thread', () => { + const bus = new InMemoryEventBus() + bus.publish({ kind: 'heartbeat', seq: 1, timestamp: 't', threadId: 'a' }) + bus.publish({ kind: 'heartbeat', seq: 1, timestamp: 't', threadId: 'b' }) + + bus.clearThread('a') + + expect(bus.snapshotSince('a', 0)).toEqual([]) + expect(bus.highestSeq('a')).toBe(0) + expect(bus.allocateSeq('a')).toBe(1) + expect(bus.snapshotSince('b', 0)).toHaveLength(1) + expect(bus.highestSeq('b')).toBe(1) + }) }) describe('InMemoryApprovalGate', () => { @@ -169,21 +183,25 @@ describe('LocalToolHost', () => { ).rejects.toThrow(/aborted/) }) - it('rejects user_input as unadvertised when no GUI gate is available', async () => { + it('returns an error when user_input has no GUI gate', async () => { const host = new LocalToolHost({ tools: defaultLocalTools }) - await expect( - host.execute( - { callId: 'c1', toolName: 'user_input', arguments: { prompt: '?' } }, - { - threadId: 'th', - turnId: 'tu', - workspace: '/tmp', - approvalPolicy: 'on-request', - abortSignal: new AbortController().signal, - awaitApproval: async () => 'allow' - } - ) - ).rejects.toThrow(/user_input is not advertised/) + const result = await host.execute( + { callId: 'c1', toolName: 'user_input', arguments: { prompt: '?' } }, + { + threadId: 'th', + turnId: 'tu', + workspace: '/tmp', + approvalPolicy: 'on-request', + abortSignal: new AbortController().signal, + awaitApproval: async () => 'allow' + } + ) + expect(result.item).toMatchObject({ + kind: 'tool_result', + toolName: 'user_input', + isError: true, + output: { error: 'GUI user input is not available in this runtime context' } + }) }) it('updates in-memory session items in place', async () => { diff --git a/kun/tests/runtime-factory.test.ts b/kun/tests/runtime-factory.test.ts index a5538de5b..ace804a4f 100644 --- a/kun/tests/runtime-factory.test.ts +++ b/kun/tests/runtime-factory.test.ts @@ -146,4 +146,45 @@ describe('runtime factory usage carryover', () => { await runtime.shutdown?.() } }) + + it('clears per-thread runtime memory when a thread is deleted', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'kun-runtime-delete-')) + 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' }, + capabilities: KunCapabilitiesConfig.parse({}) + }) + + try { + const threadId = 'thr_deleted' + await runtime.threadService.create( + { workspace: '/tmp/workspace', model: 'model-before', mode: 'agent' }, + { id: threadId } + ) + runtime.usageService.record(threadId, usage({ promptTokens: 10, completionTokens: 5 })) + + expect(await runtime.threadService.delete(threadId)).toBe(true) + expect(runtime.eventBus.snapshotSince(threadId, 0)).toEqual([]) + expect(runtime.usageService.forThread(threadId).totalTokens).toBe(0) + + await runtime.threadService.create( + { workspace: '/tmp/workspace', model: 'model-before', mode: 'agent' }, + { id: threadId } + ) + expect(runtime.eventBus.snapshotSince(threadId, 0).map((event) => event.seq)).toEqual([1]) + } finally { + await runtime.shutdown?.() + } + }) }) diff --git a/kun/tests/user-input-disabled.test.ts b/kun/tests/user-input-disabled.test.ts index da8db62ac..0d0df1ca0 100644 --- a/kun/tests/user-input-disabled.test.ts +++ b/kun/tests/user-input-disabled.test.ts @@ -3,7 +3,7 @@ import type { ModelRequest, ModelStreamChunk } from '../src/ports/model-client.j import { bootstrapThread, makeHarness } from './loop-test-harness.js' describe('agent loop: disableUserInput turns (IM bridges)', () => { - it('hides GUI input tools and rejects stray calls instead of blocking', async () => { + it('keeps a stable catalog but tells the model not to call GUI input tools', async () => { let calls = 0 const seenRequests: ModelRequest[] = [] const h = makeHarness({ @@ -32,12 +32,13 @@ describe('agent loop: disableUserInput turns (IM bridges)', () => { const status = await h.loop.runTurn(h.threadId, h.turnId) expect(status).toBe('completed') - const advertised = seenRequests[0]?.tools.map((tool) => tool.name) ?? [] - expect(advertised).not.toContain('user_input') - expect(advertised).not.toContain('request_user_input') - expect(seenRequests[0]?.contextInstructions?.join(' ')).toMatch( - /Interactive user input is unavailable/ - ) + expect(seenRequests).toHaveLength(2) + for (const request of seenRequests) { + const advertised = request.tools.map((tool) => tool.name) + expect(advertised).toContain('user_input') + expect(advertised).toContain('request_user_input') + expect(request.contextInstructions?.join(' ')).toMatch(/Do not call either tool/) + } const result = (await h.sessionStore.loadItems(h.threadId)).find( (item) => item.kind === 'tool_result' && item.toolName === 'request_user_input' @@ -69,7 +70,7 @@ describe('agent loop: disableUserInput turns (IM bridges)', () => { expect(advertised).toContain('user_input') expect(advertised).toContain('request_user_input') expect(seenRequests[0]?.contextInstructions?.join(' ') ?? '').not.toMatch( - /Interactive user input is unavailable/ + /Do not call either tool/ ) }) }) diff --git a/kun/vitest.config.ts b/kun/vitest.config.ts index b6fed462e..93e5e4ce1 100644 --- a/kun/vitest.config.ts +++ b/kun/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ test: { environment: 'node', include: ['tests/**/*.test.ts', 'src/**/*.test.ts'], - globals: false + globals: false, + ...(process.platform === 'win32' ? { maxWorkers: 2 } : {}) } }) diff --git a/release/release-v0.2.24.md b/release/release-v0.2.24.md new file mode 100644 index 000000000..7e81f175e --- /dev/null +++ b/release/release-v0.2.24.md @@ -0,0 +1,64 @@ +# Kun v0.2.24 + +这一版是 v0.2.23 之后的一次体验与运行时稳定性更新。重点是让 Composer 更会处理提示词和文件引用,让远程 IM 会话可以管理线程、模型、目标和附件,同时补上模型请求重试、上下文压缩、后台子代理、Write 自动保存和 Design 标注等一批日常会碰到的细节。 + +### Composer 与模型请求 + +- 新增 Composer 提示词优化能力。开启后,输入框会出现优化按钮,可以把口语化或零散的需求整理成更清楚的 Agent prompt。 +- 提示词优化可单独配置 provider、model、自定义优化 prompt 和超时时间;未指定时会继承 Kun 当前的小模型或主模型。 +- 提示词优化请求支持 OpenAI Chat Completions、Anthropic Messages 和 OpenAI Responses 兼容端点,并沿用现有代理配置。 +- 文件 `@` 引用补强了路径式补全、目录/文件去重、Enter/Tab 提交、带空格路径引用和删除引用后的同步移除。 +- 模型选择器不再把线程里的旧模型硬塞回候选列表,减少 stale model 混入;推理强度选项收进二级菜单,工具栏在窄宽度下也更稳。 +- 模型请求新增可配置 HTTP 重试策略,可针对 429、503 等临时失败设置重试次数、初始延迟和状态码范围。 + +### 远程 IM 与 Connect + +- 远程 IM 命令大幅补强,新增 `/stop`、`/pwd`、`/usage`、`/list-skills`、`/list-mcp`、`/list-goal`、`/goal`、`/list-threads`、`/current`、`/switch`、`/list-model` 等命令。 +- IM 会话可以列出最近 Kun 线程并切换到指定线程,适合从微信、飞书或 Telegram 继续已有工作。 +- IM 模型切换改为先 `/list-model` 查看所有可用文本模型,再用 `/model <序号>` 选择;每个 IM conversation 会记录自己的 provider/model。 +- IM 侧会明确告诉 Agent 当前没有 GUI 输入工具可用,减少远程聊天里等待弹窗确认或结构化输入的情况。 +- 新增 `send_im_attachment` 工具,Agent 可以把工作区内生成的文件排队作为 IM 附件发送;路径会限制在 workspace 内,并限制数量和大小。 +- 飞书与 Telegram 的流式回复、错误提示、生成文件回传和定时任务创建路径做了稳定性修复,失败信息会统一带 Kun 前缀。 + +### Runtime、子代理与上下文 + +- 上下文压缩默认使用模型摘要,摘要上限提高到 2048 tokens;失败、超时或空结果时仍会自动降级到本地摘要骨架。 +- 压缩摘要会更努力保留用户请求、任务列表、错误、文件路径、命令结果和未完成事项,减少长任务续跑时丢关键中间项。 +- 子代理委派会继承父回合当前 provider,避免子任务意外回落到默认供应商。 +- detached/background 子代理完成后会把结果通知回父线程;中断父任务时也会更可靠地中断相关子任务。 +- 子代理卡片补充后台标识、稳定排序、耗时、队列、token 和工具调用信息,多个子代理的时间显示也更一致。 +- 删除线程时会清理对应记忆引用;repo map cache 和 Write 检索索引 cache 增加边界,降低长时间使用后的内存增长风险。 +- 后台 shell 在生成摘要前会先 flush 输出,避免长命令最后一段输出没有进入结果说明。 + +### Workbench、Write 与 Design + +- Loop 入口移动到顶部模式 tabs,工作区线程可以从侧栏上下文菜单归档。 +- 侧栏导航、timeline rail、右侧 rail tooltip、pin 高亮和聊天跳转 rail 做了对齐与紧凑化处理。 +- Write 新增自动保存开关和保存间隔设置;默认仍开启自动保存,用户也可以完全关闭。 +- Write 初始化后会刷新工作区文件树,避免新建/切换工作区后目录状态不及时。 +- Design 多页面生成现在必须显式开启多页面模式,不再因为空画布或从零开始就自动走多页面路线。 +- Design system 读取更严格,遇到格式异常时会尽量恢复可用 token/component,避免坏数据拖垮整个设计工作区。 +- 图片标注文字工具支持多行输入、自动调整输入框、IME 组合输入和应用前自动提交草稿;标注文本也会进入设计说明。 +- 更新后 release note 提示不会在开发环境或非升级版本中误弹。 + +### 测试与维护 + +- 补充提示词优化、IM 附件、IM 命令、子代理委派、上下文压缩、设计系统恢复、图片标注、Write 自动保存、Composer 文件引用和 provider retry 相关测试。 +- Windows 测试稳定性继续收紧,减少 Vitest worker 竞争、junction/symlink fixture、后台 shell 生命周期和 LSP URI 差异带来的偶发失败。 +- IPC schema 覆盖新增 prompt optimization、provider retry、Write 自动保存和 IM conversation provider/model 字段。 + +### 升级说明 + +- 从 `v0.2.23` 升级可直接通过 GUI 更新。 +- Composer 提示词优化默认关闭;需要在 Kun/Agent 设置中启用并选择对应模型后才会显示优化按钮。 +- 模型请求重试是可配置能力,默认不会额外增加请求次数;如果你的 provider 偶发 429/503,可以在供应商配置中开启。 +- Write 自动保存默认保持开启,默认间隔为 180 秒;如果你更偏好手动保存,可以在 Write 设置中关闭。 +- 远程 IM 旧会话会继续可用;新的模型切换方式建议先发送 `/list-model` 再用 `/model <序号>`。 + +### 完整变更 + +https://github.com/KunAgent/Kun/compare/v0.2.23...v0.2.24 + +### 总结 + +v0.2.24 不追求一个巨大的新模式,而是把 Kun 的日常工作流往前推了一段:输入前能整理 prompt,聊天中能更稳地引用文件,远程 IM 能真正管理线程和附件,长上下文和后台子代理也更不容易丢状态。它是一版让 v0.2.22 以来的新能力更适合长期使用的打磨版本。 diff --git a/src/main/claw-runtime-helpers.test.ts b/src/main/claw-runtime-helpers.test.ts index 8df239990..2d20230d1 100644 --- a/src/main/claw-runtime-helpers.test.ts +++ b/src/main/claw-runtime-helpers.test.ts @@ -4,12 +4,31 @@ import { finalAssistantReplyText, imCompletionReplyForPush, IM_COMPLETED_NO_TEXT_REPLY, + createDeferredCloseHandle, subscribeRuntimeThreadEvents, type RuntimeSseEvent, type ThreadDetailJson, type TurnItemJson } from './claw-runtime-helpers' +describe('createDeferredCloseHandle', () => { + it('closes a subscription that resolves after the caller already closed', async () => { + let resolveSetup!: (handle: { close: () => void }) => void + const setup = new Promise<{ close: () => void }>((resolve) => { + resolveSetup = resolve + }) + const close = vi.fn() + const handle = createDeferredCloseHandle(setup, vi.fn()) + + handle.close() + resolveSetup({ close }) + await setup + await Promise.resolve() + + expect(close).toHaveBeenCalledTimes(1) + }) +}) + // Global fetch mock for subscribeRuntimeThreadEvents const originalFetch = globalThis.fetch let fetchMock: ReturnType diff --git a/src/main/claw-runtime-helpers.ts b/src/main/claw-runtime-helpers.ts index ee0f94f71..3c6d67791 100644 --- a/src/main/claw-runtime-helpers.ts +++ b/src/main/claw-runtime-helpers.ts @@ -31,7 +31,8 @@ export type ClawRuntimeDeps = { sendWeixinBridgeMessage?: (options: { accountId: string to: string - text: string + text?: string + files?: readonly { path: string; fileName: string }[] }) => Promise<{ ok: true; messageId: string } | { ok: false; message: string }> /** WeChat owner (`ilink_user_id`) for a bridge account; '' when unknown. */ resolveWeixinAccountUserId?: (accountId: string) => Promise @@ -52,7 +53,11 @@ export type ClawRuntimeDeps = { export type ThreadRecordJson = { id: string + title?: string status?: string + workspace?: string + createdAt?: string + updatedAt?: string } export type TurnRecordJson = { @@ -299,7 +304,8 @@ function generatedFilesFromToolResult( (item.toolName === 'generate_image' || item.toolName === 'generate_speech' || item.toolName === 'generate_music' || - item.toolName === 'generate_video') && + item.toolName === 'generate_video' || + item.toolName === 'send_im_attachment') && Array.isArray(output.files) ) { return output.files @@ -589,6 +595,32 @@ export async function readRequestBody(req: IncomingMessage): Promise { export type SseSubscriber = (signal: AbortSignal) => { close: () => void } +export function createDeferredCloseHandle( + setup: Promise<{ close: () => void }>, + onError: (error: unknown) => void +): { close: () => void } { + let handle: { close: () => void } | null = null + let closed = false + void setup.then( + (resolved) => { + if (closed) { + resolved.close() + return + } + handle = resolved + }, + onError + ) + return { + close: () => { + if (closed) return + closed = true + handle?.close() + handle = null + } + } +} + export type RuntimeSseEvent = { kind: string; turnId?: string; item?: { text?: unknown }; seq?: number; [key: string]: unknown } /** diff --git a/src/main/claw-runtime.test.ts b/src/main/claw-runtime.test.ts index 6e3f529d6..174339b84 100644 --- a/src/main/claw-runtime.test.ts +++ b/src/main/claw-runtime.test.ts @@ -74,6 +74,14 @@ function buildSettings(): AppSettingsV1 { } } +function expectImRuntimePrompt(prompt: string | undefined, userText: string): void { + expect(prompt).toContain('') + expect(prompt).toContain('false') + expect(prompt).toContain('') +} + function buildConversation(overrides: Partial = {}): ClawImConversationV1 { return { id: 'conv_1', @@ -161,215 +169,1170 @@ function mutableSettingsStore(initialSettings: AppSettingsV1): { } describe('ClawRuntime', () => { - it('bases Feishu conversation workspaces on the configured Claw workspace', () => { + it('returns help and starts a new topic for IM commands', async () => { const settings = buildSettings() - settings.claw.im.workspaceRoot = '/tmp/claw-default' - const channel: ClawImChannelV1 = { - id: 'channel_1', - provider: 'feishu' as const, - label: 'Phone', - enabled: true, - model: 'auto', - threadId: '', - workspaceRoot: '', - agentProfile: { - name: 'kun', - description: '', - identity: '', - personality: '', - userContext: '', - replyRules: '' - }, - conversations: [], - createdAt: '2026-06-02T00:00:00.000Z', - updatedAt: '2026-06-02T00:00:00.000Z' - } - settings.claw.channels = [channel] + settings.claw.im.provider = 'weixin' + settings.claw.channels = [ + buildChannel({ + provider: 'weixin', + label: 'WeChat', + threadId: 'thr_old', + conversations: [ + buildConversation({ localThreadId: 'thr_old' }), + buildConversation({ id: 'conv_2', chatId: 'oc_chat_b', localThreadId: 'thr_new' }) + ] + }) + ] + const { current, store } = mutableSettingsStore(settings) const runtime = createClawRuntime({ - store: { load: vi.fn(async () => settings), patch: vi.fn(async () => settings) } as never, + store: store as never, runtimeRequest: vi.fn() as never, logError: () => undefined }) + const handle = (runtime as unknown as { + handleIncomingImCommand: ( + settingsArg: AppSettingsV1, + input: { + text: string + channel: ClawImChannelV1 + conversation: ClawImConversationV1 + } + ) => Promise + }).handleIncomingImCommand.bind(runtime) + + const help = await handle(settings, { + text: '/help', + channel: settings.claw.channels[0], + conversation: settings.claw.channels[0].conversations[0] + }) + expect(help).toContain('/list-skills') + expect(help).toContain('/list-mcp') + expect(help).toContain('/list-goal') + expect(help).toContain('/goal') + expect(help).toContain('/stop') + expect(help).toContain('/new') + + const reply = await handle(settings, { + text: '/new', + channel: settings.claw.channels[0], + conversation: settings.claw.channels[0].conversations[0] + }) + expect(reply).toContain('new topic') + expect(current().claw.channels[0].threadId).toBe('') + expect(current().claw.channels[0].conversations[0].localThreadId).toBe('') + }) - const root = (runtime as unknown as { - resolveIncomingWorkspaceRoot: ( + it('lists available Kun skills for an incoming IM command', async () => { + const settings = buildSettings() + const { store } = mutableSettingsStore(settings) + const runtimeRequest = vi.fn(async () => ({ + ok: true, + status: 200, + body: JSON.stringify({ + enabled: true, + skills: [ + { + id: 'documents', + name: 'Documents', + description: 'Create and edit documents', + source: 'global' + } + ] + }) + })) + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest: runtimeRequest as never, + logError: () => undefined + }) + + const reply = await (runtime as unknown as { + handleIncomingImCommand: ( settingsArg: AppSettingsV1, - channelArg: typeof channel, - conversationArg: undefined, - remoteSessionArg: { chatId: string; threadId: string } - ) => string - }).resolveIncomingWorkspaceRoot(settings, channel, undefined, { - chatId: 'oc_chat_a', - threadId: '' + input: { text: string } + ) => Promise + }).handleIncomingImCommand(settings, { text: '/list-skills' }) + + expect(runtimeRequest).toHaveBeenCalledWith(settings, '/v1/skills', { method: 'GET' }) + expect(reply).toContain('documents') + expect(reply).toContain('Documents') + }) + + it('lists Kun MCP servers for an incoming IM command', async () => { + const settings = buildSettings() + const { store } = mutableSettingsStore(settings) + const runtimeRequest = vi.fn(async () => ({ + ok: true, + status: 200, + body: JSON.stringify({ + mcpServers: [ + { + id: 'github', + enabled: true, + available: true, + status: 'connected', + transport: 'stdio', + toolCount: 12 + }, + { + id: 'docs', + enabled: true, + available: false, + status: 'error', + transport: 'http', + toolCount: 0, + lastError: 'connect failed' + } + ] + }) + })) + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest: runtimeRequest as never, + logError: () => undefined }) - expect(root).toBe('/tmp/claw-default/conversations/oc_chat_a') + const reply = await (runtime as unknown as { + handleIncomingImCommand: ( + settingsArg: AppSettingsV1, + input: { text: string } + ) => Promise + }).handleIncomingImCommand(settings, { text: '/list-mcp' }) + + expect(runtimeRequest).toHaveBeenCalledWith(settings, '/v1/runtime/tools', { method: 'GET' }) + expect(reply).toContain('github') + expect(reply).toContain('12 tools') + expect(reply).toContain('docs') + expect(reply).toContain('connect failed') }) - it('repairs legacy Feishu conversation workspaces created from an empty channel root', () => { + it('shows the current Kun thread workspace for an incoming IM command', async () => { const settings = buildSettings() - settings.claw.im.workspaceRoot = '/tmp/claw-default' - const conversation: ClawImConversationV1 = { - id: 'conv_1', - chatId: 'oc_chat_a', - remoteThreadId: '', - latestMessageId: 'msg_1', - senderId: 'ou_1', - senderName: 'Alice', - localThreadId: 'thr_1', - workspaceRoot: '/conversations/oc_chat_a', - createdAt: '2026-06-02T00:00:00.000Z', - updatedAt: '2026-06-02T00:00:00.000Z' - } - const channel: ClawImChannelV1 = { - id: 'channel_1', - provider: 'feishu' as const, - label: 'Phone', - enabled: true, - model: 'auto', - threadId: '', - workspaceRoot: '', - agentProfile: { - name: 'kun', - description: '', - identity: '', - personality: '', - userContext: '', - replyRules: '' - }, - conversations: [conversation], - createdAt: '2026-06-02T00:00:00.000Z', - updatedAt: '2026-06-02T00:00:00.000Z' - } - settings.claw.channels = [channel] + settings.claw.channels = [ + buildChannel({ + threadId: 'thr_workspace', + conversations: [buildConversation({ localThreadId: 'thr_workspace' })] + }) + ] + const { store } = mutableSettingsStore(settings) + const runtimeRequest = vi.fn(async () => ({ + ok: true, + status: 200, + body: JSON.stringify({ + id: 'thr_workspace', + workspace: '/tmp/workspace/conversations/oc_chat_a', + turns: [] + }) + })) const runtime = createClawRuntime({ - store: { load: vi.fn(async () => settings), patch: vi.fn(async () => settings) } as never, - runtimeRequest: vi.fn() as never, + store: store as never, + runtimeRequest: runtimeRequest as never, logError: () => undefined }) - const root = (runtime as unknown as { - resolveIncomingWorkspaceRoot: ( + const reply = await (runtime as unknown as { + handleIncomingImCommand: ( settingsArg: AppSettingsV1, - channelArg: typeof channel, - conversationArg: typeof conversation, - remoteSessionArg: { chatId: string; threadId: string } - ) => string - }).resolveIncomingWorkspaceRoot(settings, channel, conversation, { - chatId: 'oc_chat_a', - threadId: '' + input: { + text: string + channel: ClawImChannelV1 + conversation: ClawImConversationV1 + } + ) => Promise + }).handleIncomingImCommand(settings, { + text: '/pwd', + channel: settings.claw.channels[0], + conversation: settings.claw.channels[0].conversations[0] }) - expect(root).toBe('/tmp/claw-default/conversations/oc_chat_a') + expect(runtimeRequest).toHaveBeenCalledWith(settings, '/v1/threads/thr_workspace', { method: 'GET' }) + expect(reply).toContain('/tmp/workspace/conversations/oc_chat_a') }) - it('delegates reminder creation to Schedule without writing claw tasks', async () => { + it('does not reuse the channel thread for a different incoming IM conversation', async () => { const settings = buildSettings() - settings.claw.im.enabled = true - const store = { - load: vi.fn(async () => settings), - patch: vi.fn(async () => settings) - } - const createScheduledTaskFromText = vi.fn(async () => ({ - kind: 'created' as const, - taskId: 'schedule-task-1', - title: 'Reminder', - scheduleAt: '2026-06-03T09:00:00.000+08:00', - confirmationText: 'Scheduled.' - })) + settings.claw.channels = [ + buildChannel({ + threadId: 'thr_old_chat', + conversations: [buildConversation({ chatId: 'oc_chat_a', localThreadId: 'thr_old_chat' })] + }) + ] + const { store } = mutableSettingsStore(settings) const runtime = createClawRuntime({ store: store as never, runtimeRequest: vi.fn() as never, - logError: () => undefined, - createScheduledTaskFromText + logError: () => undefined }) - const body = JSON.stringify({ text: 'Remind me tomorrow to ship the review.' }) - const req = { - method: 'POST', - url: settings.claw.im.path, - headers: {}, - async *[Symbol.asyncIterator]() { - yield Buffer.from(body) + + const reply = await (runtime as unknown as { + handleIncomingImCommand: ( + settingsArg: AppSettingsV1, + input: { + text: string + channel: ClawImChannelV1 + remoteSession: Pick & { + messageId: string + threadId: string + } + } + ) => Promise + }).handleIncomingImCommand(settings, { + text: '/current', + channel: settings.claw.channels[0], + remoteSession: { + chatId: 'oc_chat_b', + messageId: 'om_current', + threadId: '', + senderId: 'ou_2', + senderName: 'Bob' } - } - let status = 0 - let responseBody = '' - const res = { - writeHead: vi.fn((nextStatus: number) => { - status = nextStatus - }), - end: vi.fn((payload: string) => { - responseBody = payload - }) - } + }) - await (runtime as unknown as { - handleWebhook: (request: typeof req, response: typeof res) => Promise - }).handleWebhook(req, res) + expect(reply).toContain('[Kun]') + expect(reply).toContain('not connected') + }) - expect(status).toBe(200) - expect(JSON.parse(responseBody)).toEqual({ + it('shows current Kun thread token usage with provider and model for an incoming IM command', async () => { + const settings = buildSettings() + settings.provider.providers = [buildModelProvider()] + settings.claw.channels = [ + buildChannel({ + providerId: 'minimax', + model: 'MiniMax-M3', + threadId: 'thr_usage', + conversations: [buildConversation({ localThreadId: 'thr_usage' })] + }) + ] + const { store } = mutableSettingsStore(settings) + const runtimeRequest = vi.fn(async () => ({ ok: true, - createdTaskId: 'schedule-task-1', - reply: 'Scheduled.' + status: 200, + body: JSON.stringify({ + group_by: 'thread', + buckets: [ + { + thread_id: 'thr_usage', + input_tokens: 123, + output_tokens: 45, + reasoning_tokens: 0, + cached_tokens: 20, + cache_miss_tokens: 105, + total_tokens: 168, + cost_usd: 0.00123, + cost_cny: 0.0089, + turns: 3, + last_turn_cache_hit_rate: null, + last_turn_cacheable_hit_rate: null, + last_turn_total_input_hit_rate: null, + last_cache_miss_reasons: [], + last_cache_suggestions: [] + } + ], + totals: { + input_tokens: 123, + output_tokens: 45, + reasoning_tokens: 0, + cached_tokens: 20, + cache_miss_tokens: 105, + total_tokens: 168, + cost_usd: 0.00123, + cost_cny: 0.0089, + cache_savings_usd: 0, + cache_savings_cny: 0, + token_economy_savings_tokens: 0, + token_economy_savings_usd: 0, + token_economy_savings_cny: 0, + turns: 3, + thread_count: 1, + cache_hit_rate: null + } + }) + })) + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest: runtimeRequest as never, + logError: () => undefined }) - expect(createScheduledTaskFromText).toHaveBeenCalledWith('Remind me tomorrow to ship the review.', { - workspaceRoot: settings.workspaceRoot, - clawChannelId: null, - providerId: null, - modelHint: settings.claw.im.model, - mode: settings.claw.im.mode + + const reply = await (runtime as unknown as { + handleIncomingImCommand: ( + settingsArg: AppSettingsV1, + input: { + text: string + channel: ClawImChannelV1 + conversation: ClawImConversationV1 + } + ) => Promise + }).handleIncomingImCommand(settings, { + text: '/usage', + channel: settings.claw.channels[0], + conversation: settings.claw.channels[0].conversations[0] }) - expect(store.patch).not.toHaveBeenCalled() - expect(settings.claw.tasks).toHaveLength(1) + + expect(runtimeRequest).toHaveBeenCalledWith( + settings, + '/v1/usage?group_by=thread&thread_id=thr_usage', + { method: 'GET' } + ) + expect(reply).toContain('minimax') + expect(reply).toContain('MiniMax-M3') + expect(reply).toContain('total 168') + expect(reply).toContain('input 123') + expect(reply).toContain('output 45') }) - it('reports that scheduled tasks have moved to Schedule', async () => { + it('returns a Kun-prefixed concrete error when an IM runtime command fails', async () => { const settings = buildSettings() - let currentSettings = settings - const runtimeRequest = vi.fn(async (_settings, path, init) => { - if (path === '/v1/threads') { - return { ok: true, status: 200, body: JSON.stringify({ id: 'thr_1' }) } - } - if (path === '/v1/threads/thr_1') { - return { ok: true, status: 200, body: '{}' } - } - if (path === '/v1/threads/thr_1/turns') { - return { ok: true, status: 202, body: JSON.stringify({ threadId: 'thr_1', turnId: 'turn_1' }) } - } - throw new Error(`unexpected path ${path}`) - }) - const store = { - load: vi.fn(async () => currentSettings), - patch: vi.fn(async (partial: Partial) => { - currentSettings = { - ...currentSettings, - ...partial, - claw: { ...currentSettings.claw, ...(partial.claw ?? {}) } - } - return currentSettings - }) - } + const { store } = mutableSettingsStore(settings) + const runtimeRequest = vi.fn(async () => ({ + ok: false, + status: 503, + body: JSON.stringify({ message: 'runtime is offline' }) + })) const runtime = createClawRuntime({ store: store as never, - runtimeRequest, + runtimeRequest: runtimeRequest as never, logError: () => undefined }) - const result = await runtime.runTask('task_1') + const reply = await (runtime as unknown as { + handleIncomingImCommandSafely: ( + settingsArg: AppSettingsV1, + input: { text: string } + ) => Promise + }).handleIncomingImCommandSafely(settings, { text: '/list-threads' }) - expect(result).toEqual({ ok: false, message: 'Claw scheduled tasks have moved to Schedule.' }) - expect(runtimeRequest).not.toHaveBeenCalled() + expect(reply).toBe('[Kun] runtime is offline') }) - it('accepts assistant_text items when waiting for a Claw turn result', async () => { + it('prefixes successful IM slash command replies as Kun system messages', async () => { const settings = buildSettings() - settings.agents.kun.approvalPolicy = 'on-request' - settings.agents.kun.sandboxMode = 'workspace-write' - const runtimeRequest = vi.fn(async (_settings, path, init) => { - if (path === '/v1/threads') { - return { ok: true, status: 200, body: JSON.stringify({ id: 'thr_1' }) } - } + const { store } = mutableSettingsStore(settings) + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest: vi.fn() as never, + logError: () => undefined + }) + + const reply = await (runtime as unknown as { + handleIncomingImCommandSafely: ( + settingsArg: AppSettingsV1, + input: { text: string } + ) => Promise + }).handleIncomingImCommandSafely(settings, { text: '/help' }) + + expect(reply).toMatch(/^\[Kun\] /) + expect(reply).toContain('/list-threads') + }) + + it('shows the current Kun thread goal for an IM list-goal command', async () => { + const settings = buildSettings() + settings.claw.channels = [ + buildChannel({ + threadId: 'thr_goal', + conversations: [buildConversation({ localThreadId: 'thr_goal' })] + }) + ] + const { store } = mutableSettingsStore(settings) + const runtimeRequest = vi.fn(async (_settingsArg: AppSettingsV1, path: string, init: { method?: string; body?: string }) => { + if (path === '/v1/threads/thr_goal/goal' && init.method === 'GET') { + return { + ok: true, + status: 200, + body: JSON.stringify({ + goal: { + threadId: 'thr_goal', + objective: 'Read document A', + status: 'active', + tokensUsed: 12 + } + }) + } + } + if (path === '/v1/threads/thr_goal/goal' && init.method === 'POST') { + return { + ok: true, + status: 200, + body: JSON.stringify({ + goal: { + threadId: 'thr_goal', + objective: JSON.parse(init.body ?? '{}').objective, + status: 'active', + tokensUsed: 0 + } + }) + } + } + return { ok: false, status: 404, body: '{}' } + }) + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest: runtimeRequest as never, + logError: () => undefined + }) + const handle = (runtime as unknown as { + handleIncomingImCommand: ( + settingsArg: AppSettingsV1, + input: { + text: string + channel: ClawImChannelV1 + conversation: ClawImConversationV1 + } + ) => Promise + }).handleIncomingImCommand.bind(runtime) + + const shown = await handle(settings, { + text: '/list-goal', + channel: settings.claw.channels[0], + conversation: settings.claw.channels[0].conversations[0] + }) + expect(shown).toContain('Read document A') + }) + + it('rejects empty and duplicate Kun thread goals for IM commands', async () => { + const settings = buildSettings() + settings.claw.channels = [ + buildChannel({ + threadId: 'thr_goal', + conversations: [buildConversation({ localThreadId: 'thr_goal' })] + }) + ] + const { store } = mutableSettingsStore(settings) + const runtimeRequest = vi.fn(async (_settingsArg: AppSettingsV1, path: string, init: { method?: string; body?: string }) => { + if (path === '/v1/threads/thr_goal/goal' && init.method === 'GET') { + return { + ok: true, + status: 200, + body: JSON.stringify({ + goal: { + threadId: 'thr_goal', + objective: 'Read document A', + status: 'active', + tokensUsed: 12 + } + }) + } + } + if (path === '/v1/threads/thr_goal/goal' && init.method === 'POST') { + return { + ok: true, + status: 200, + body: JSON.stringify({ + goal: { + threadId: 'thr_goal', + objective: JSON.parse(init.body ?? '{}').objective, + status: 'active', + tokensUsed: 0 + } + }) + } + } + return { ok: false, status: 404, body: '{}' } + }) + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest: runtimeRequest as never, + logError: () => undefined + }) + const handle = (runtime as unknown as { + handleIncomingImCommand: ( + settingsArg: AppSettingsV1, + input: { + text: string + channel: ClawImChannelV1 + conversation: ClawImConversationV1 + } + ) => Promise + }).handleIncomingImCommand.bind(runtime) + + const empty = await handle(settings, { + text: '/goal ', + channel: settings.claw.channels[0], + conversation: settings.claw.channels[0].conversations[0] + }) + expect(empty).toContain('[Kun]') + expect(empty).toContain('requires an objective') + + const changed = await handle(settings, { + text: '/goal Finish document B', + channel: settings.claw.channels[0], + conversation: settings.claw.channels[0].conversations[0] + }) + expect(changed).toContain('already has a goal') + expect(changed).toContain('[Kun]') + expect(changed).toContain('Read document A') + expect(runtimeRequest.mock.calls.some(([, path, init]) => + path === '/v1/threads/thr_goal/goal' && init.method === 'POST' + )).toBe(false) + }) + + it('sets a Kun thread goal when the current IM thread has none', async () => { + const settings = buildSettings() + settings.claw.channels = [ + buildChannel({ + threadId: 'thr_goal', + conversations: [buildConversation({ localThreadId: 'thr_goal' })] + }) + ] + const { store } = mutableSettingsStore(settings) + const runtimeRequest = vi.fn(async (_settingsArg: AppSettingsV1, path: string, init: { method?: string; body?: string }) => { + if (path === '/v1/threads/thr_goal/goal' && init.method === 'GET') { + return { + ok: true, + status: 200, + body: JSON.stringify({ goal: null }) + } + } + if (path === '/v1/threads/thr_goal/goal' && init.method === 'POST') { + return { + ok: true, + status: 200, + body: JSON.stringify({ + goal: { + threadId: 'thr_goal', + objective: JSON.parse(init.body ?? '{}').objective, + status: 'active', + tokensUsed: 0 + } + }) + } + } + return { ok: false, status: 404, body: '{}' } + }) + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest: runtimeRequest as never, + logError: () => undefined + }) + + const changed = await (runtime as unknown as { + handleIncomingImCommand: ( + settingsArg: AppSettingsV1, + input: { + text: string + channel: ClawImChannelV1 + conversation: ClawImConversationV1 + } + ) => Promise + }).handleIncomingImCommand(settings, { + text: '/goal Finish document B', + channel: settings.claw.channels[0], + conversation: settings.claw.channels[0].conversations[0] + }) + + expect(changed).toContain('Finish document B') + expect(runtimeRequest).toHaveBeenCalledWith( + settings, + '/v1/threads/thr_goal/goal', + { + method: 'POST', + body: JSON.stringify({ objective: 'Finish document B' }) + } + ) + }) + + it('stops the running turn in the current IM thread', async () => { + const settings = buildSettings() + settings.claw.channels = [ + buildChannel({ + threadId: 'thr_stop', + conversations: [buildConversation({ localThreadId: 'thr_stop' })] + }) + ] + const { store } = mutableSettingsStore(settings) + const runtimeRequest = vi.fn(async (_settingsArg: AppSettingsV1, path: string, init: { method?: string; body?: string }) => { + if (path === '/v1/threads/thr_stop' && init.method === 'GET') { + return { + ok: true, + status: 200, + body: JSON.stringify({ + id: 'thr_stop', + turns: [ + { id: 'turn_done', status: 'completed' }, + { id: 'turn_running', status: 'running' } + ] + }) + } + } + if (path === '/v1/threads/thr_stop/turns/turn_running/interrupt' && init.method === 'POST') { + return { + ok: true, + status: 200, + body: JSON.stringify({ threadId: 'thr_stop', turnId: 'turn_running', status: 'aborted' }) + } + } + return { ok: false, status: 404, body: '{}' } + }) + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest: runtimeRequest as never, + logError: () => undefined + }) + + const reply = await (runtime as unknown as { + handleIncomingImCommand: ( + settingsArg: AppSettingsV1, + input: { + text: string + channel: ClawImChannelV1 + conversation: ClawImConversationV1 + } + ) => Promise + }).handleIncomingImCommand(settings, { + text: '/stop', + channel: settings.claw.channels[0], + conversation: settings.claw.channels[0].conversations[0] + }) + + expect(reply).toContain('turn_running') + expect(runtimeRequest).toHaveBeenCalledWith( + settings, + '/v1/threads/thr_stop/turns/turn_running/interrupt', + { + method: 'POST', + body: JSON.stringify({ discard: false }) + } + ) + }) + + it('returns a Kun-prefixed error when there is no running IM turn to stop', async () => { + const settings = buildSettings() + settings.claw.channels = [ + buildChannel({ + threadId: 'thr_stop', + conversations: [buildConversation({ localThreadId: 'thr_stop' })] + }) + ] + const { store } = mutableSettingsStore(settings) + const runtimeRequest = vi.fn(async () => ({ + ok: true, + status: 200, + body: JSON.stringify({ + id: 'thr_stop', + turns: [{ id: 'turn_done', status: 'completed' }] + }) + })) + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest: runtimeRequest as never, + logError: () => undefined + }) + + const reply = await (runtime as unknown as { + handleIncomingImCommand: ( + settingsArg: AppSettingsV1, + input: { + text: string + channel: ClawImChannelV1 + conversation: ClawImConversationV1 + } + ) => Promise + }).handleIncomingImCommand(settings, { + text: '/stop', + channel: settings.claw.channels[0], + conversation: settings.claw.channels[0].conversations[0] + }) + + expect(reply).toContain('[Kun]') + expect(reply).toContain('no running task') + }) + + it('lists recent Kun threads for an incoming WeChat command', async () => { + const settings = buildSettings() + settings.claw.im.provider = 'weixin' + settings.claw.im.recentThreadListLimit = 3 + settings.claw.channels = [ + buildChannel({ + provider: 'weixin', + label: 'WeChat', + threadId: 'thr_old', + conversations: [ + buildConversation({ localThreadId: 'thr_old' }), + buildConversation({ id: 'conv_2', chatId: 'oc_chat_b', localThreadId: 'thr_new' }) + ] + }) + ] + const { store } = mutableSettingsStore(settings) + const runtimeRequest = vi.fn(async () => ({ + ok: true, + status: 200, + body: JSON.stringify({ + threads: [ + { + id: 'thr_new', + title: '[Claw IM:WeChat] Document B', + status: 'idle', + updatedAt: '2026-06-03T00:00:00.000Z' + }, + { + id: 'thr_old', + title: '[Claw IM:WeChat] Document A', + status: 'idle', + updatedAt: '2026-06-02T00:00:00.000Z' + }, + { + id: 'thr_other', + title: 'Desktop chat', + status: 'idle', + updatedAt: '2026-06-04T00:00:00.000Z' + } + ] + }) + })) + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest: runtimeRequest as never, + logError: () => undefined + }) + + const reply = await (runtime as unknown as { + handleIncomingImCommand: ( + settingsArg: AppSettingsV1, + input: { + text: string + channel: ClawImChannelV1 + conversation: ClawImConversationV1 + } + ) => Promise + }).handleIncomingImCommand(settings, { + text: '/list-threads', + channel: settings.claw.channels[0], + conversation: settings.claw.channels[0].conversations[0] + }) + + expect(runtimeRequest).toHaveBeenCalledWith(settings, '/v1/threads?limit=3', { method: 'GET' }) + expect(reply).toContain('Desktop chat') + expect(reply).toContain('Document B') + expect(reply).toContain('Document A') + }) + + it('switches the current WeChat conversation to a selected Kun thread', async () => { + const settings = buildSettings() + settings.claw.im.provider = 'weixin' + settings.claw.channels = [ + buildChannel({ + provider: 'weixin', + label: 'WeChat', + threadId: 'thr_old', + conversations: [ + buildConversation({ localThreadId: 'thr_old' }), + buildConversation({ id: 'conv_2', chatId: 'oc_chat_b', localThreadId: 'thr_new' }) + ] + }) + ] + const { current, store } = mutableSettingsStore(settings) + const runtimeRequest = vi.fn(async () => ({ + ok: true, + status: 200, + body: JSON.stringify({ + threads: [ + { + id: 'thr_old', + title: '[Claw IM:WeChat] Document A', + status: 'idle', + updatedAt: '2026-06-02T00:00:00.000Z' + }, + { + id: 'thr_new', + title: '[Claw IM:WeChat] Document B', + status: 'idle', + updatedAt: '2026-06-03T00:00:00.000Z' + } + ] + }) + })) + const notifyChannelActivity = vi.fn() + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest: runtimeRequest as never, + logError: () => undefined, + notifyChannelActivity + }) + + const reply = await (runtime as unknown as { + handleIncomingImCommand: ( + settingsArg: AppSettingsV1, + input: { + text: string + channel: ClawImChannelV1 + conversation: ClawImConversationV1 + remoteSession: Pick & { + messageId: string + threadId: string + } + } + ) => Promise + }).handleIncomingImCommand(settings, { + text: '/switch 1', + channel: settings.claw.channels[0], + conversation: settings.claw.channels[0].conversations[0], + remoteSession: { + chatId: 'oc_chat_a', + messageId: 'om_switch', + threadId: '', + senderId: 'ou_1', + senderName: 'Alice' + } + }) + + expect(reply).toContain('thr_new') + expect(reply).toContain('also held by another IM chat') + expect(current().claw.channels[0].threadId).toBe('thr_new') + expect(current().claw.channels[0].conversations[0].localThreadId).toBe('thr_new') + expect(current().claw.channels[0].conversations[0].latestMessageId).toBe('om_switch') + expect(notifyChannelActivity).toHaveBeenCalledWith({ channelId: 'channel_1', threadId: 'thr_new' }) + }) + + it('switches WeChat conversations by the number shown in the recent thread list', async () => { + const settings = buildSettings() + settings.claw.im.provider = 'weixin' + settings.claw.im.recentThreadListLimit = 5 + settings.claw.channels = [ + buildChannel({ + provider: 'weixin', + label: 'WeChat', + threadId: 'thr_current', + conversations: [buildConversation({ localThreadId: 'thr_current' })] + }) + ] + const { current, store } = mutableSettingsStore(settings) + const runtimeRequest = vi.fn(async () => ({ + ok: true, + status: 200, + body: JSON.stringify({ + threads: [ + { id: 'thr_current', title: 'New chat', status: 'idle', updatedAt: '2026-06-05T00:00:00.000Z' }, + { id: 'thr_two', title: '你好', status: 'idle', updatedAt: '2026-06-04T00:00:00.000Z' }, + { id: 'thr_three', title: 'mock retry success', status: 'idle', updatedAt: '2026-06-03T00:00:00.000Z' }, + { id: 'thr_four', title: '触发一次后台任务,睡眠 20s', status: 'idle', updatedAt: '2026-06-02T00:00:00.000Z' }, + { id: 'thr_five', title: '创建后台休眠任务并输出字符串', status: 'idle', updatedAt: '2026-06-01T00:00:00.000Z' } + ] + }) + })) + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest: runtimeRequest as never, + logError: () => undefined + }) + + const reply = await (runtime as unknown as { + handleIncomingImCommand: ( + settingsArg: AppSettingsV1, + input: { + text: string + channel: ClawImChannelV1 + conversation: ClawImConversationV1 + } + ) => Promise + }).handleIncomingImCommand(settings, { + text: '/switch 4', + channel: settings.claw.channels[0], + conversation: settings.claw.channels[0].conversations[0] + }) + + expect(runtimeRequest).toHaveBeenCalledWith(settings, '/v1/threads?limit=5', { method: 'GET' }) + expect(reply).toContain('thr_four') + expect(current().claw.channels[0].threadId).toBe('thr_four') + expect(current().claw.channels[0].conversations[0].localThreadId).toBe('thr_four') + }) + + it('does not switch WeChat conversations by thread title', async () => { + const settings = buildSettings() + settings.claw.im.provider = 'weixin' + settings.claw.channels = [ + buildChannel({ + provider: 'weixin', + label: 'WeChat', + threadId: 'thr_old', + conversations: [buildConversation({ localThreadId: 'thr_old' })] + }) + ] + const { current, store } = mutableSettingsStore(settings) + const runtimeRequest = vi.fn(async () => ({ + ok: true, + status: 200, + body: JSON.stringify({ + threads: [ + { + id: 'thr_new', + title: '[Claw IM:WeChat] Document B', + status: 'idle', + updatedAt: '2026-06-03T00:00:00.000Z' + } + ] + }) + })) + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest: runtimeRequest as never, + logError: () => undefined + }) + + const reply = await (runtime as unknown as { + handleIncomingImCommand: ( + settingsArg: AppSettingsV1, + input: { + text: string + channel: ClawImChannelV1 + conversation: ClawImConversationV1 + } + ) => Promise + }).handleIncomingImCommand(settings, { + text: '/switch Document B', + channel: settings.claw.channels[0], + conversation: settings.claw.channels[0].conversations[0] + }) + + expect(reply).toContain('[Kun]') + expect(reply).toContain('Could not find') + expect(current().claw.channels[0].threadId).toBe('thr_old') + expect(store.patch).not.toHaveBeenCalled() + }) + + it('does not report switch success when no IM channel can persist the selection', async () => { + const settings = buildSettings() + const { store } = mutableSettingsStore(settings) + const runtimeRequest = vi.fn(async () => ({ + ok: true, + status: 200, + body: JSON.stringify({ + threads: [ + { + id: 'thr_new', + title: 'Document B', + status: 'idle', + updatedAt: '2026-06-03T00:00:00.000Z' + } + ] + }) + })) + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest: runtimeRequest as never, + logError: () => undefined + }) + + const reply = await (runtime as unknown as { + handleIncomingImCommand: ( + settingsArg: AppSettingsV1, + input: { text: string } + ) => Promise + }).handleIncomingImCommand(settings, { text: '/switch 1' }) + + expect(reply).toContain('[Kun]') + expect(reply).not.toContain('Switched') + expect(store.patch).not.toHaveBeenCalled() + }) + + it('bases Feishu conversation workspaces on the configured Claw workspace', () => { + const settings = buildSettings() + settings.claw.im.workspaceRoot = '/tmp/claw-default' + const channel: ClawImChannelV1 = { + id: 'channel_1', + provider: 'feishu' as const, + label: 'Phone', + enabled: true, + model: 'auto', + threadId: '', + workspaceRoot: '', + agentProfile: { + name: 'kun', + description: '', + identity: '', + personality: '', + userContext: '', + replyRules: '' + }, + conversations: [], + createdAt: '2026-06-02T00:00:00.000Z', + updatedAt: '2026-06-02T00:00:00.000Z' + } + settings.claw.channels = [channel] + const runtime = createClawRuntime({ + store: { load: vi.fn(async () => settings), patch: vi.fn(async () => settings) } as never, + runtimeRequest: vi.fn() as never, + logError: () => undefined + }) + + const root = (runtime as unknown as { + resolveIncomingWorkspaceRoot: ( + settingsArg: AppSettingsV1, + channelArg: typeof channel, + conversationArg: undefined, + remoteSessionArg: { chatId: string; threadId: string } + ) => string + }).resolveIncomingWorkspaceRoot(settings, channel, undefined, { + chatId: 'oc_chat_a', + threadId: '' + }) + + expect(root).toBe('/tmp/claw-default/conversations/oc_chat_a') + }) + + it('repairs legacy Feishu conversation workspaces created from an empty channel root', () => { + const settings = buildSettings() + settings.claw.im.workspaceRoot = '/tmp/claw-default' + const conversation: ClawImConversationV1 = { + id: 'conv_1', + chatId: 'oc_chat_a', + remoteThreadId: '', + latestMessageId: 'msg_1', + senderId: 'ou_1', + senderName: 'Alice', + localThreadId: 'thr_1', + workspaceRoot: '/conversations/oc_chat_a', + createdAt: '2026-06-02T00:00:00.000Z', + updatedAt: '2026-06-02T00:00:00.000Z' + } + const channel: ClawImChannelV1 = { + id: 'channel_1', + provider: 'feishu' as const, + label: 'Phone', + enabled: true, + model: 'auto', + threadId: '', + workspaceRoot: '', + agentProfile: { + name: 'kun', + description: '', + identity: '', + personality: '', + userContext: '', + replyRules: '' + }, + conversations: [conversation], + createdAt: '2026-06-02T00:00:00.000Z', + updatedAt: '2026-06-02T00:00:00.000Z' + } + settings.claw.channels = [channel] + const runtime = createClawRuntime({ + store: { load: vi.fn(async () => settings), patch: vi.fn(async () => settings) } as never, + runtimeRequest: vi.fn() as never, + logError: () => undefined + }) + + const root = (runtime as unknown as { + resolveIncomingWorkspaceRoot: ( + settingsArg: AppSettingsV1, + channelArg: typeof channel, + conversationArg: typeof conversation, + remoteSessionArg: { chatId: string; threadId: string } + ) => string + }).resolveIncomingWorkspaceRoot(settings, channel, conversation, { + chatId: 'oc_chat_a', + threadId: '' + }) + + expect(root).toBe('/tmp/claw-default/conversations/oc_chat_a') + }) + + it('delegates reminder creation to Schedule without writing claw tasks', async () => { + const settings = buildSettings() + settings.claw.im.enabled = true + const store = { + load: vi.fn(async () => settings), + patch: vi.fn(async () => settings) + } + const createScheduledTaskFromText = vi.fn(async () => ({ + kind: 'created' as const, + taskId: 'schedule-task-1', + title: 'Reminder', + scheduleAt: '2026-06-03T09:00:00.000+08:00', + confirmationText: 'Scheduled.' + })) + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest: vi.fn() as never, + logError: () => undefined, + createScheduledTaskFromText + }) + const body = JSON.stringify({ text: 'Remind me tomorrow to ship the review.' }) + const req = { + method: 'POST', + url: settings.claw.im.path, + headers: {}, + async *[Symbol.asyncIterator]() { + yield Buffer.from(body) + } + } + let status = 0 + let responseBody = '' + const res = { + writeHead: vi.fn((nextStatus: number) => { + status = nextStatus + }), + end: vi.fn((payload: string) => { + responseBody = payload + }) + } + + await (runtime as unknown as { + handleWebhook: (request: typeof req, response: typeof res) => Promise + }).handleWebhook(req, res) + + expect(status).toBe(200) + expect(JSON.parse(responseBody)).toEqual({ + ok: true, + createdTaskId: 'schedule-task-1', + reply: 'Scheduled.' + }) + expect(createScheduledTaskFromText).toHaveBeenCalledWith('Remind me tomorrow to ship the review.', { + workspaceRoot: settings.workspaceRoot, + clawChannelId: null, + providerId: 'deepseek', + modelHint: 'deepseek-v4-flash', + mode: settings.claw.im.mode + }) + expect(store.patch).not.toHaveBeenCalled() + expect(settings.claw.tasks).toHaveLength(1) + }) + + it('reports that scheduled tasks have moved to Schedule', async () => { + const settings = buildSettings() + let currentSettings = settings + const runtimeRequest = vi.fn(async (_settings, path, init) => { + if (path === '/v1/threads') { + return { ok: true, status: 200, body: JSON.stringify({ id: 'thr_1' }) } + } + if (path === '/v1/threads/thr_1') { + return { ok: true, status: 200, body: '{}' } + } + if (path === '/v1/threads/thr_1/turns') { + return { ok: true, status: 202, body: JSON.stringify({ threadId: 'thr_1', turnId: 'turn_1' }) } + } + throw new Error(`unexpected path ${path}`) + }) + const store = { + load: vi.fn(async () => currentSettings), + patch: vi.fn(async (partial: Partial) => { + currentSettings = { + ...currentSettings, + ...partial, + claw: { ...currentSettings.claw, ...(partial.claw ?? {}) } + } + return currentSettings + }) + } + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest, + logError: () => undefined + }) + + const result = await runtime.runTask('task_1') + + expect(result).toEqual({ ok: false, message: 'Claw scheduled tasks have moved to Schedule.' }) + expect(runtimeRequest).not.toHaveBeenCalled() + }) + + it('accepts assistant_text items when waiting for a Claw turn result', async () => { + const settings = buildSettings() + settings.agents.kun.approvalPolicy = 'on-request' + settings.agents.kun.sandboxMode = 'workspace-write' + const runtimeRequest = vi.fn(async (_settings, path, init) => { + if (path === '/v1/threads') { + return { ok: true, status: 200, body: JSON.stringify({ id: 'thr_1' }) } + } if (path === '/v1/threads/thr_1' && init?.method === 'PATCH') { return { ok: true, status: 200, body: '{}' } } @@ -929,7 +1892,7 @@ describe('ClawRuntime', () => { expect(runtimeRequest).not.toHaveBeenCalled() expect(send).toHaveBeenCalledWith( 'oc_chat_a', - { markdown: 'Started a new topic. The next message will create a fresh local conversation.' }, + { markdown: '[Kun] Started a new topic. The next message will create a fresh local conversation.' }, { replyTo: 'om_inbound', replyInThread: false } ) expect(current().claw.channels[0].threadId).toBe('') @@ -937,7 +1900,7 @@ describe('ClawRuntime', () => { expect(current().claw.channels[0].remoteSession?.messageId).toBe('om_inbound') }) - it('handles Feishu model commands locally for the current IM channel', async () => { + it('handles Feishu model commands locally for the current IM conversation', async () => { const settings = buildSettings() settings.claw.im.enabled = true settings.claw.channels = [buildChannel()] @@ -974,28 +1937,33 @@ describe('ClawRuntime', () => { chatType: 'p2p', mentionedBot: false, mentionAll: false, - content: '-model flash', + content: '-model 2', rawContentType: 'text', mentions: [] }) expect(runtimeRequest).not.toHaveBeenCalled() - expect(current().claw.channels[0].model).toBe('deepseek-v4-flash') + expect(current().claw.channels[0].model).toBe('auto') + expect(current().claw.channels[0].conversations[0]).toMatchObject({ + chatId: 'oc_chat_a', + providerId: 'deepseek', + model: 'deepseek-v4-pro' + }) expect(send).toHaveBeenCalledWith( 'oc_chat_a', - { markdown: 'Claw IM model switched to `deepseek-v4-flash`.' }, + { markdown: '[Kun] Claw IM model switched to `deepseek-v4-pro` with provider `deepseek`.' }, { replyTo: 'om_inbound', replyInThread: false } ) }) - it('lists and switches IM model providers locally for the current channel', async () => { + it('lists models with numbers and switches by model number', async () => { const settings = buildSettings() settings.claw.im.enabled = true settings.provider.providers = [ - ...settings.provider.providers, - buildModelProvider() + buildModelProvider({ id: 'minimax-a', name: 'MiniMax A', models: ['MiniMax-M3'] }), + buildModelProvider({ id: 'minimax', name: 'MiniMax', models: ['MiniMax-M2.7', 'MiniMax-M3'] }) ] - settings.claw.channels = [buildChannel()] + settings.claw.channels = [buildChannel({ providerId: 'minimax', model: 'MiniMax-M2.7' })] const { current, store } = mutableSettingsStore(settings) const runtimeRequest = vi.fn() const send = vi.fn(async () => ({ messageId: 'om_sent' })) @@ -1034,42 +2002,91 @@ describe('ClawRuntime', () => { mentions: [] }) - await handleFeishuMessage('/provider', 'om_provider_list') - expect(runtimeRequest).not.toHaveBeenCalled() + await handleFeishuMessage('/list-model', 'om_model_list') expect(send).toHaveBeenLastCalledWith( 'oc_chat_a', - { markdown: expect.stringContaining('Loaded providers:') }, - { replyTo: 'om_provider_list', replyInThread: false } + { markdown: expect.stringContaining('Available models:') }, + { replyTo: 'om_model_list', replyInThread: false } ) - const providerListCall = send.mock.calls[send.mock.calls.length - 1] as unknown as [ + const modelListCall = send.mock.calls[send.mock.calls.length - 1] as unknown as [ string, { markdown?: string }, Record ] - expect(providerListCall[1]).toMatchObject({ markdown: expect.stringContaining('`minimax`') }) + expect(modelListCall[1]).toMatchObject({ markdown: expect.stringContaining('3. `MiniMax-M3` · provider `minimax-a`') }) + expect(modelListCall[1]).toMatchObject({ markdown: expect.stringContaining('5. `MiniMax-M3` · provider `minimax`') }) + expect(modelListCall[1]).toMatchObject({ markdown: expect.stringContaining('provider `minimax`') }) + + await handleFeishuMessage('/model MiniMax-M3', 'om_model_name_switch') + expect(current().claw.channels[0]).toMatchObject({ + providerId: 'minimax', + model: 'MiniMax-M2.7' + }) + expect(send).toHaveBeenLastCalledWith( + 'oc_chat_a', + { markdown: expect.stringContaining('[Kun] Invalid model number `MiniMax-M3`.') }, + { replyTo: 'om_model_name_switch', replyInThread: false } + ) - await handleFeishuMessage('/provider minimax', 'om_provider_switch') + await handleFeishuMessage('/model 5', 'om_model_switch') expect(current().claw.channels[0]).toMatchObject({ providerId: 'minimax', model: 'MiniMax-M2.7' }) + expect(current().claw.channels[0].conversations[0]).toMatchObject({ + chatId: 'oc_chat_a', + providerId: 'minimax', + model: 'MiniMax-M3' + }) expect(send).toHaveBeenLastCalledWith( 'oc_chat_a', - { markdown: 'IM provider switched to `minimax`; model is `MiniMax-M2.7`. Send `/model` to list models for this provider.' }, - { replyTo: 'om_provider_switch', replyInThread: false } + { markdown: '[Kun] Claw IM model switched to `MiniMax-M3` with provider `minimax`.' }, + { replyTo: 'om_model_switch', replyInThread: false } ) }) - it('lists and switches models only within the current IM provider', async () => { + it('uses the current IM provider when starting an agent turn', async () => { const settings = buildSettings() settings.claw.im.enabled = true + settings.claw.im.responseTimeoutMs = 2_000 settings.provider.providers = [ ...settings.provider.providers, buildModelProvider() ] - settings.claw.channels = [buildChannel({ providerId: 'minimax', model: 'MiniMax-M2.7' })] - const { current, store } = mutableSettingsStore(settings) - const runtimeRequest = vi.fn() + settings.claw.channels = [buildChannel({ + providerId: 'minimax', + model: 'MiniMax-M3', + threadId: 'thr_minimax', + conversations: [buildConversation({ localThreadId: 'thr_minimax' })] + })] + const { store } = mutableSettingsStore(settings) + const runtimeRequest = vi.fn(async (requestSettings: AppSettingsV1, path, init) => { + expect(requestSettings.agents.kun.providerId).toBe('minimax') + expect(requestSettings.agents.kun.model).toBe('MiniMax-M3') + if (path === '/v1/threads/thr_minimax/turns' && init?.method === 'POST') { + const body = JSON.parse(init?.body ?? '{}') as { model?: string } + expect(body.model).toBe('MiniMax-M3') + return { ok: true, status: 202, body: JSON.stringify({ threadId: 'thr_minimax', turnId: 'turn_minimax' }) } + } + if (path === '/v1/threads/thr_minimax' && init?.method === 'GET') { + return { + ok: true, + status: 200, + body: JSON.stringify({ + id: 'thr_minimax', + status: 'idle', + turns: [ + { + id: 'turn_minimax', + status: 'completed', + items: [{ kind: 'assistant_text', text: 'hello from minimax' }] + } + ] + }) + } + } + throw new Error(`unexpected path ${path}`) + }) const send = vi.fn(async () => ({ messageId: 'om_sent' })) const runtime = createClawRuntime({ store: store as never, @@ -1079,57 +2096,126 @@ describe('ClawRuntime', () => { ;(runtime as unknown as { feishuChannels: Map }) .feishuChannels .set('channel_1', { send }) - const handleFeishuMessage = (content: string, messageId: string): Promise => - (runtime as unknown as { - handleFeishuMessage: (channelId: string, message: { - chatId: string - messageId: string - senderId: string - senderName?: string - chatType: 'p2p' | 'group' - mentionedBot: boolean - mentionAll: boolean - content: string - rawContentType: string - mentions: unknown[] - }) => Promise - }).handleFeishuMessage('channel_1', { - chatId: 'oc_chat_a', - messageId, - senderId: 'ou_1', - senderName: 'Alice', - chatType: 'p2p', - mentionedBot: false, - mentionAll: false, - content, - rawContentType: 'text', - mentions: [] - }) - await handleFeishuMessage('/model', 'om_model_list') - expect(send).toHaveBeenLastCalledWith( + await (runtime as unknown as { + handleFeishuMessage: (channelId: string, message: { + chatId: string + messageId: string + senderId: string + senderName?: string + chatType: 'p2p' | 'group' + mentionedBot: boolean + mentionAll: boolean + content: string + rawContentType: string + mentions: unknown[] + }) => Promise + }).handleFeishuMessage('channel_1', { + chatId: 'oc_chat_a', + messageId: 'om_inbound', + senderId: 'ou_1', + senderName: 'Alice', + chatType: 'p2p', + mentionedBot: false, + mentionAll: false, + content: 'hello', + rawContentType: 'text', + mentions: [] + }) + + expect(send).toHaveBeenCalledWith( 'oc_chat_a', - { markdown: expect.stringContaining('Available models:') }, - { replyTo: 'om_model_list', replyInThread: false } + { markdown: 'hello from minimax' }, + { replyTo: 'om_inbound', replyInThread: false } ) - const modelListCall = send.mock.calls[send.mock.calls.length - 1] as unknown as [ - string, - { markdown?: string }, - Record + }) + + it('falls back to the first provider model when the stored IM model was removed', async () => { + const settings = buildSettings() + settings.claw.im.enabled = true + settings.claw.im.responseTimeoutMs = 2_000 + settings.provider.providers = [ + ...settings.provider.providers, + buildModelProvider({ models: ['MiniMax-M2.7'] }) ] - expect(modelListCall[1]).toMatchObject({ markdown: expect.stringContaining('`MiniMax-M3`') }) - expect(modelListCall[1]).toMatchObject({ markdown: expect.not.stringContaining('deepseek-v4-flash') }) + settings.claw.channels = [buildChannel({ + providerId: 'minimax', + model: 'MiniMax-M3', + threadId: 'thr_minimax', + conversations: [buildConversation({ localThreadId: 'thr_minimax' })] + })] + const { store } = mutableSettingsStore(settings) + const runtimeRequest = vi.fn(async (requestSettings: AppSettingsV1, path, init) => { + expect(requestSettings.agents.kun.providerId).toBe('minimax') + expect(requestSettings.agents.kun.model).toBe('MiniMax-M2.7') + if (path === '/v1/threads/thr_minimax/turns' && init?.method === 'POST') { + const body = JSON.parse(init?.body ?? '{}') as { model?: string } + expect(body.model).toBe('MiniMax-M2.7') + return { ok: true, status: 202, body: JSON.stringify({ threadId: 'thr_minimax', turnId: 'turn_minimax' }) } + } + if (path === '/v1/threads/thr_minimax' && init?.method === 'GET') { + return { + ok: true, + status: 200, + body: JSON.stringify({ + id: 'thr_minimax', + status: 'idle', + turns: [ + { + id: 'turn_minimax', + status: 'completed', + items: [{ kind: 'assistant_text', text: 'hello from fallback model' }] + } + ] + }) + } + } + throw new Error(`unexpected path ${path}`) + }) + const send = vi.fn(async () => ({ messageId: 'om_sent' })) + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest: runtimeRequest as never, + logError: () => undefined + }) + ;(runtime as unknown as { feishuChannels: Map }) + .feishuChannels + .set('channel_1', { send }) + + await (runtime as unknown as { + handleFeishuMessage: (channelId: string, message: { + chatId: string + messageId: string + senderId: string + senderName?: string + chatType: 'p2p' | 'group' + mentionedBot: boolean + mentionAll: boolean + content: string + rawContentType: string + mentions: unknown[] + }) => Promise + }).handleFeishuMessage('channel_1', { + chatId: 'oc_chat_a', + messageId: 'om_inbound', + senderId: 'ou_1', + senderName: 'Alice', + chatType: 'p2p', + mentionedBot: false, + mentionAll: false, + content: 'hello', + rawContentType: 'text', + mentions: [] + }) - await handleFeishuMessage('/model MiniMax-M3', 'om_model_switch') - expect(current().claw.channels[0].model).toBe('MiniMax-M3') - expect(send).toHaveBeenLastCalledWith( + expect(send).toHaveBeenCalledWith( 'oc_chat_a', - { markdown: 'Claw IM model switched to `MiniMax-M3`.' }, - { replyTo: 'om_model_switch', replyInThread: false } + { markdown: 'hello from fallback model' }, + { replyTo: 'om_inbound', replyInThread: false } ) }) - it('uses the current IM provider when starting an agent turn', async () => { + it('uses the current IM conversation provider when starting an agent turn', async () => { const settings = buildSettings() settings.claw.im.enabled = true settings.claw.im.responseTimeoutMs = 2_000 @@ -1140,30 +2226,36 @@ describe('ClawRuntime', () => { settings.claw.channels = [buildChannel({ providerId: 'minimax', model: 'MiniMax-M3', - threadId: 'thr_minimax', - conversations: [buildConversation({ localThreadId: 'thr_minimax' })] + threadId: 'thr_channel', + conversations: [ + buildConversation({ + localThreadId: 'thr_deepseek', + providerId: 'deepseek', + model: 'deepseek-v4-flash' + }) + ] })] const { store } = mutableSettingsStore(settings) const runtimeRequest = vi.fn(async (requestSettings: AppSettingsV1, path, init) => { - expect(requestSettings.agents.kun.providerId).toBe('minimax') - expect(requestSettings.agents.kun.model).toBe('MiniMax-M3') - if (path === '/v1/threads/thr_minimax/turns' && init?.method === 'POST') { + expect(requestSettings.agents.kun.providerId).toBe('deepseek') + expect(requestSettings.agents.kun.model).toBe('deepseek-v4-flash') + if (path === '/v1/threads/thr_deepseek/turns' && init?.method === 'POST') { const body = JSON.parse(init?.body ?? '{}') as { model?: string } - expect(body.model).toBe('MiniMax-M3') - return { ok: true, status: 202, body: JSON.stringify({ threadId: 'thr_minimax', turnId: 'turn_minimax' }) } + expect(body.model).toBe('deepseek-v4-flash') + return { ok: true, status: 202, body: JSON.stringify({ threadId: 'thr_deepseek', turnId: 'turn_deepseek' }) } } - if (path === '/v1/threads/thr_minimax' && init?.method === 'GET') { + if (path === '/v1/threads/thr_deepseek' && init?.method === 'GET') { return { ok: true, status: 200, body: JSON.stringify({ - id: 'thr_minimax', + id: 'thr_deepseek', status: 'idle', turns: [ { - id: 'turn_minimax', + id: 'turn_deepseek', status: 'completed', - items: [{ kind: 'assistant_text', text: 'hello from minimax' }] + items: [{ kind: 'assistant_text', text: 'hello from deepseek' }] } ] }) @@ -1209,7 +2301,7 @@ describe('ClawRuntime', () => { expect(send).toHaveBeenCalledWith( 'oc_chat_a', - { markdown: 'hello from minimax' }, + { markdown: 'hello from deepseek' }, { replyTo: 'om_inbound', replyInThread: false } ) }) @@ -1762,7 +2854,8 @@ describe('ClawRuntime', () => { expect(welcomeCall[0]).toBe('oc_chat_a') expect(welcomeCall[1].markdown).toContain('Kun') expect(welcomeCall[1].markdown).toContain('`/new`') - expect(welcomeCall[1].markdown).toContain('`/model`') + expect(welcomeCall[1].markdown).toContain('`/list-model`') + expect(welcomeCall[1].markdown).toContain('`/model `') expect(welcomeCall[2]).toEqual({}) expect(current().claw.channels[0].welcomeSentAt).toBeTruthy() @@ -2625,66 +3718,327 @@ describe('ClawRuntime', () => { sendWeixinBridgeMessage }) - const result = await runtime.mirrorThreadMessageToIm('thr_weixin', 'hello from local', 'assistant') + const result = await runtime.mirrorThreadMessageToIm('thr_weixin', 'hello from local', 'assistant') + + expect(result).toEqual({ ok: true }) + expect(sendWeixinBridgeMessage).toHaveBeenCalledWith({ + accountId: 'wx_account', + to: 'wx_user_1', + text: 'hello from local' + }) + }) + + it('sends the latest generated workspace file to Feishu when the user asks for it', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'deepseek-gui-feishu-file-')) + const filePath = join(workspaceRoot, 'hello.md') + await writeFile(filePath, '# Hello\n') + const realFilePath = await realpath(filePath) + try { + const settings = buildSettings() + settings.claw.im.enabled = true + settings.claw.im.responseTimeoutMs = 2_000 + const conversation: ClawImConversationV1 = { + id: 'conv_1', + chatId: 'oc_chat_a', + remoteThreadId: '', + latestMessageId: 'om_previous', + senderId: 'ou_1', + senderName: 'Alice', + localThreadId: 'thr_1', + workspaceRoot, + createdAt: '2026-06-02T00:00:00.000Z', + updatedAt: '2026-06-02T00:00:00.000Z' + } + const channel: ClawImChannelV1 = { + id: 'channel_1', + provider: 'feishu' as const, + label: 'Phone', + enabled: true, + model: 'auto', + threadId: '', + workspaceRoot, + agentProfile: { + name: 'kun', + description: '', + identity: '', + personality: '', + userContext: '', + replyRules: '' + }, + conversations: [conversation], + welcomeSentAt: '2026-06-02T00:00:00.000Z', + createdAt: '2026-06-02T00:00:00.000Z', + updatedAt: '2026-06-02T00:00:00.000Z' + } + settings.claw.channels = [channel] + const store = { + load: vi.fn(async () => settings), + patch: vi.fn(async () => settings) + } + const runtimeRequest = vi.fn(async (_settings, path, init) => { + if (path === '/v1/threads/thr_1/turns') { + return { ok: true, status: 202, body: JSON.stringify({ threadId: 'thr_1', turnId: 'turn_2' }) } + } + if (path === '/v1/threads/thr_1' && init?.method === 'GET') { + return { + ok: true, + status: 200, + body: JSON.stringify({ + id: 'thr_1', + status: 'idle', + turns: [ + { + id: 'turn_1', + status: 'completed', + items: [ + { + kind: 'tool_result', + toolKind: 'file_change', + output: { + path: filePath, + relative_path: 'hello.md', + bytes_written: 8 + }, + isError: false + } + ] + }, + { + id: 'turn_2', + status: 'completed', + items: [ + { + kind: 'assistant_text', + text: '我无法直接通过飞书发送文件给你,但文件已经创建在 workspace 中。' + } + ] + } + ] + }) + } + } + throw new Error(`unexpected path ${path}`) + }) + const send = vi.fn(async () => ({ messageId: 'om_sent' })) + const addReaction = vi.fn(async () => 'rc_file_1') + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest, + logError: () => undefined + }) + ;(runtime as unknown as { feishuChannels: Map }) + .feishuChannels + .set('channel_1', { send, addReaction }) + + await (runtime as unknown as { + handleFeishuMessage: (channelId: string, message: { + chatId: string + messageId: string + threadId?: string + senderId: string + senderName?: string + chatType: 'p2p' | 'group' + mentionedBot: boolean + mentionAll: boolean + content: string + rawContentType: string + mentions: unknown[] + }) => Promise + }).handleFeishuMessage('channel_1', { + chatId: 'oc_chat_a', + messageId: 'om_inbound', + senderId: 'ou_1', + senderName: 'Alice', + chatType: 'p2p', + mentionedBot: false, + mentionAll: false, + content: '发给我', + rawContentType: 'text', + mentions: [] + }) + + expect(send).toHaveBeenNthCalledWith( + 1, + 'oc_chat_a', + { markdown: '可以,我把 hello.md 作为附件发给你。' }, + { replyTo: 'om_inbound', replyInThread: false } + ) + expect(send).toHaveBeenNthCalledWith( + 2, + 'oc_chat_a', + { file: { source: realFilePath, fileName: 'hello.md' } }, + { replyTo: 'om_inbound', replyInThread: false } + ) + // The direct-file path is fast (synchronous file lookup + upload) and + // The direct-file path is fast (synchronous file lookup + upload) and + // must NOT add a pending reaction — that would be visually noisy. + const addReactionSpy = (runtime as unknown as { feishuChannels: Map }> }) + .feishuChannels.get('channel_1')?.addReaction + expect(addReactionSpy).not.toHaveBeenCalled() + } finally { + await rm(workspaceRoot, { recursive: true, force: true }) + } + }) + + it('sends generated image tool output to Feishu for image requests', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'deepseek-gui-feishu-image-')) + const imageDir = join(workspaceRoot, '.deepseekgui-images') + const imagePath = join(imageDir, 'img-20260611000100-abcd.png') + await mkdir(imageDir, { recursive: true }) + await writeFile(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])) + const realImagePath = await realpath(imagePath) + try { + const settings = buildSettings() + settings.claw.im.enabled = true + settings.claw.im.responseTimeoutMs = 2_000 + settings.agents.kun.imageGeneration = { + enabled: true, + providerId: '', + protocol: 'openai-images', + baseUrl: 'https://images.example.test/v1', + apiKey: 'sk-image', + model: 'test-image-model', + defaultSize: '1024x1024', + quality: 'auto', + timeoutMs: 180000 + } + settings.claw.channels = [ + buildChannel({ + threadId: 'thr_1', + workspaceRoot, + conversations: [buildConversation({ localThreadId: 'thr_1', workspaceRoot })] + }) + ] + const store = { + load: vi.fn(async () => settings), + patch: vi.fn(async () => settings) + } + const runtimeRequest = vi.fn(async (_settings, path, init) => { + if (path === '/v1/threads/thr_1/turns') { + const body = JSON.parse(init?.body ?? '{}') as { prompt?: string } + expectImRuntimePrompt(body.prompt, '帮我生成一张图片') + expect(body.prompt).not.toContain('generate_image') + return { ok: true, status: 202, body: JSON.stringify({ threadId: 'thr_1', turnId: 'turn_img' }) } + } + if (path === '/v1/threads/thr_1' && init?.method === 'GET') { + return { + ok: true, + status: 200, + body: JSON.stringify({ + id: 'thr_1', + status: 'idle', + turns: [ + { + id: 'turn_img', + status: 'completed', + items: [ + { + kind: 'tool_result', + toolName: 'generate_image', + toolKind: 'tool_call', + output: { + files: [{ + absolutePath: imagePath, + relativePath: '.deepseekgui-images/img-20260611000100-abcd.png', + mimeType: 'image/png' + }], + endpoint: 'generations' + }, + isError: false + }, + { + kind: 'assistant_text', + text: '图片已生成。' + } + ] + } + ] + }) + } + } + throw new Error(`unexpected path ${path}`) + }) + const send = vi.fn(async () => ({ messageId: 'om_sent' })) + const addReaction = vi.fn(async () => 'rc_image_1') + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest, + logError: () => undefined + }) + ;(runtime as unknown as { feishuChannels: Map }) + .feishuChannels + .set('channel_1', { send, addReaction }) + + await (runtime as unknown as { + handleFeishuMessage: (channelId: string, message: { + chatId: string + messageId: string + threadId?: string + senderId: string + senderName?: string + chatType: 'p2p' | 'group' + mentionedBot: boolean + mentionAll: boolean + content: string + rawContentType: string + mentions: unknown[] + }) => Promise + }).handleFeishuMessage('channel_1', { + chatId: 'oc_chat_a', + messageId: 'om_inbound', + senderId: 'ou_1', + senderName: 'Alice', + chatType: 'p2p', + mentionedBot: false, + mentionAll: false, + content: '帮我生成一张图片', + rawContentType: 'text', + mentions: [] + }) - expect(result).toEqual({ ok: true }) - expect(sendWeixinBridgeMessage).toHaveBeenCalledWith({ - accountId: 'wx_account', - to: 'wx_user_1', - text: 'hello from local' - }) + expect(addReaction).toHaveBeenCalledWith('om_inbound', 'OnIt') + expect(send).toHaveBeenNthCalledWith( + 1, + 'oc_chat_a', + { markdown: '图片已生成。' }, + { replyTo: 'om_inbound', replyInThread: false } + ) + expect(send).toHaveBeenNthCalledWith( + 2, + 'oc_chat_a', + { file: { source: realImagePath, fileName: 'img-20260611000100-abcd.png' } }, + { replyTo: 'om_inbound', replyInThread: false } + ) + } finally { + await rm(workspaceRoot, { recursive: true, force: true }) + } }) - it('sends the latest generated workspace file to Feishu when the user asks for it', async () => { - const workspaceRoot = await mkdtemp(join(tmpdir(), 'deepseek-gui-feishu-file-')) - const filePath = join(workspaceRoot, 'hello.md') - await writeFile(filePath, '# Hello\n') + it('sends send_im_attachment tool output to Feishu as an attachment', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'deepseek-gui-feishu-im-attachment-')) + const filePath = join(workspaceRoot, 'out.txt') + await writeFile(filePath, 'hello from im tool') const realFilePath = await realpath(filePath) try { const settings = buildSettings() settings.claw.im.enabled = true settings.claw.im.responseTimeoutMs = 2_000 - const conversation: ClawImConversationV1 = { - id: 'conv_1', - chatId: 'oc_chat_a', - remoteThreadId: '', - latestMessageId: 'om_previous', - senderId: 'ou_1', - senderName: 'Alice', - localThreadId: 'thr_1', - workspaceRoot, - createdAt: '2026-06-02T00:00:00.000Z', - updatedAt: '2026-06-02T00:00:00.000Z' - } - const channel: ClawImChannelV1 = { - id: 'channel_1', - provider: 'feishu' as const, - label: 'Phone', - enabled: true, - model: 'auto', - threadId: '', - workspaceRoot, - agentProfile: { - name: 'kun', - description: '', - identity: '', - personality: '', - userContext: '', - replyRules: '' - }, - conversations: [conversation], - welcomeSentAt: '2026-06-02T00:00:00.000Z', - createdAt: '2026-06-02T00:00:00.000Z', - updatedAt: '2026-06-02T00:00:00.000Z' - } - settings.claw.channels = [channel] + settings.claw.channels = [ + buildChannel({ + threadId: 'thr_1', + workspaceRoot, + conversations: [buildConversation({ localThreadId: 'thr_1', workspaceRoot })] + }) + ] const store = { load: vi.fn(async () => settings), patch: vi.fn(async () => settings) } const runtimeRequest = vi.fn(async (_settings, path, init) => { if (path === '/v1/threads/thr_1/turns') { - return { ok: true, status: 202, body: JSON.stringify({ threadId: 'thr_1', turnId: 'turn_2' }) } + const body = JSON.parse(init?.body ?? '{}') as { imContext?: boolean } + expect(body.imContext).toBe(true) + return { ok: true, status: 202, body: JSON.stringify({ threadId: 'thr_1', turnId: 'turn_attachment' }) } } if (path === '/v1/threads/thr_1' && init?.method === 'GET') { return { @@ -2695,29 +4049,24 @@ describe('ClawRuntime', () => { status: 'idle', turns: [ { - id: 'turn_1', + id: 'turn_attachment', status: 'completed', items: [ { kind: 'tool_result', - toolKind: 'file_change', + toolName: 'send_im_attachment', + toolKind: 'tool_call', output: { - path: filePath, - relative_path: 'hello.md', - bytes_written: 8 + files: [{ + absolutePath: filePath, + relativePath: 'out.txt', + fileName: 'out.txt' + }], + status: 'queued_for_im_attachment_delivery' }, isError: false - } - ] - }, - { - id: 'turn_2', - status: 'completed', - items: [ - { - kind: 'assistant_text', - text: '我无法直接通过飞书发送文件给你,但文件已经创建在 workspace 中。' - } + }, + { kind: 'assistant_text', text: '已经准备好。' } ] } ] @@ -2727,7 +4076,7 @@ describe('ClawRuntime', () => { throw new Error(`unexpected path ${path}`) }) const send = vi.fn(async () => ({ messageId: 'om_sent' })) - const addReaction = vi.fn(async () => 'rc_file_1') + const addReaction = vi.fn(async () => 'rc_attachment_1') const runtime = createClawRuntime({ store: store as never, runtimeRequest, @@ -2753,13 +4102,13 @@ describe('ClawRuntime', () => { }) => Promise }).handleFeishuMessage('channel_1', { chatId: 'oc_chat_a', - messageId: 'om_inbound', + messageId: 'om_inbound_attachment', senderId: 'ou_1', senderName: 'Alice', chatType: 'p2p', mentionedBot: false, mentionAll: false, - content: '发给我', + content: '请继续', rawContentType: 'text', mentions: [] }) @@ -2767,30 +4116,134 @@ describe('ClawRuntime', () => { expect(send).toHaveBeenNthCalledWith( 1, 'oc_chat_a', - { markdown: '可以,我把 hello.md 作为附件发给你。' }, - { replyTo: 'om_inbound', replyInThread: false } + { markdown: '已经准备好。' }, + { replyTo: 'om_inbound_attachment', replyInThread: false } ) expect(send).toHaveBeenNthCalledWith( 2, 'oc_chat_a', - { file: { source: realFilePath, fileName: 'hello.md' } }, - { replyTo: 'om_inbound', replyInThread: false } + { file: { source: realFilePath, fileName: 'out.txt' } }, + { replyTo: 'om_inbound_attachment', replyInThread: false } ) - // The direct-file path is fast (synchronous file lookup + upload) and - // The direct-file path is fast (synchronous file lookup + upload) and - // must NOT add a pending reaction — that would be visually noisy. - const addReactionSpy = (runtime as unknown as { feishuChannels: Map }> }) - .feishuChannels.get('channel_1')?.addReaction - expect(addReactionSpy).not.toHaveBeenCalled() } finally { await rm(workspaceRoot, { recursive: true, force: true }) } }) - it('sends generated image tool output to Feishu for image requests', async () => { - const workspaceRoot = await mkdtemp(join(tmpdir(), 'deepseek-gui-feishu-image-')) + it('pushes delayed send_im_attachment tool output to Feishu', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'deepseek-gui-feishu-delayed-attachment-')) + const filePath = join(workspaceRoot, 'delayed.txt') + await writeFile(filePath, 'hello from delayed im tool') + const realFilePath = await realpath(filePath) + try { + const settings = buildSettings() + settings.claw.im.enabled = true + settings.claw.channels = [ + buildChannel({ + threadId: 'thr_1', + workspaceRoot, + conversations: [buildConversation({ localThreadId: 'thr_1', workspaceRoot })] + }) + ] + const store = { + load: vi.fn(async () => settings), + patch: vi.fn(async () => settings) + } + const runtimeRequest = vi.fn(async (_settings, path, init) => { + if (path === '/v1/threads/thr_1' && init?.method === 'GET') { + return { + ok: true, + status: 200, + body: JSON.stringify({ + id: 'thr_1', + status: 'idle', + turns: [ + { + id: 'turn_delayed_attachment', + status: 'completed', + items: [ + { + kind: 'tool_result', + toolName: 'send_im_attachment', + toolKind: 'tool_call', + output: { + files: [{ + absolutePath: filePath, + relativePath: 'delayed.txt', + fileName: 'delayed.txt' + }], + status: 'queued_for_im_attachment_delivery' + }, + isError: false + }, + { kind: 'assistant_text', text: '已经准备好。' } + ] + } + ] + }) + } + } + throw new Error(`unexpected path ${path}`) + }) + const send = vi.fn(async () => ({ messageId: 'om_sent' })) + const addReaction = vi.fn(async () => 'rc_attachment_1') + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest, + logError: () => undefined + }) + ;(runtime as unknown as { feishuChannels: Map }) + .feishuChannels + .set('channel_1', { send, addReaction }) + + ;(runtime as unknown as { + scheduleImResultPush: ( + settings: AppSettingsV1, + input: { + channel: AppSettingsV1['claw']['channels'][number] + remoteSession: { + chatId: string + messageId: string + threadId: string + senderId: string + senderName: string + } + threadId: string + turnId: string + workspaceRoot: string + } + ) => void + }).scheduleImResultPush(settings, { + channel: settings.claw.channels[0], + remoteSession: { + chatId: 'oc_chat_a', + messageId: 'om_inbound_delayed_attachment', + threadId: '', + senderId: 'ou_1', + senderName: 'Alice' + }, + threadId: 'thr_1', + turnId: 'turn_delayed_attachment', + workspaceRoot + }) + + await vi.waitFor( + () => expect(send).toHaveBeenCalledWith( + 'oc_chat_a', + { file: { source: realFilePath, fileName: 'delayed.txt' } }, + {} + ), + { timeout: 8_000, interval: 100 } + ) + } finally { + await rm(workspaceRoot, { recursive: true, force: true }) + } + }) + + it('returns generated files in the WeChat webhook reply for image requests', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'deepseek-gui-weixin-image-')) const imageDir = join(workspaceRoot, '.deepseekgui-images') - const imagePath = join(imageDir, 'img-20260611000100-abcd.png') + const imagePath = join(imageDir, 'img-20260611000200-beef.png') await mkdir(imageDir, { recursive: true }) await writeFile(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])) const realImagePath = await realpath(imagePath) @@ -2811,31 +4264,37 @@ describe('ClawRuntime', () => { } settings.claw.channels = [ buildChannel({ - threadId: 'thr_1', - workspaceRoot, - conversations: [buildConversation({ localThreadId: 'thr_1', workspaceRoot })] + provider: 'weixin' as const, + id: 'channel_weixin', + label: 'WeChat', + threadId: 'thr_wx', + conversations: [ + buildConversation({ + chatId: 'wx_user_1', + senderId: 'wx_user_1', + localThreadId: 'thr_wx', + workspaceRoot + }) + ] }) ] - const store = { - load: vi.fn(async () => settings), - patch: vi.fn(async () => settings) - } + const { store } = mutableSettingsStore(settings) const runtimeRequest = vi.fn(async (_settings, path, init) => { - if (path === '/v1/threads/thr_1/turns') { + if (path === '/v1/threads/thr_wx/turns' && init?.method === 'POST') { const body = JSON.parse(init?.body ?? '{}') as { prompt?: string } - expect(body.prompt).toContain('generate_image') - return { ok: true, status: 202, body: JSON.stringify({ threadId: 'thr_1', turnId: 'turn_img' }) } + expectImRuntimePrompt(body.prompt, '帮我画一张猫的图片') + return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_wx_img' }) } } - if (path === '/v1/threads/thr_1' && init?.method === 'GET') { + if (path === '/v1/threads/thr_wx' && init?.method === 'GET') { return { ok: true, status: 200, body: JSON.stringify({ - id: 'thr_1', + id: 'thr_wx', status: 'idle', turns: [ { - id: 'turn_img', + id: 'turn_wx_img', status: 'completed', items: [ { @@ -2845,17 +4304,14 @@ describe('ClawRuntime', () => { output: { files: [{ absolutePath: imagePath, - relativePath: '.deepseekgui-images/img-20260611000100-abcd.png', + relativePath: '.deepseekgui-images/img-20260611000200-beef.png', mimeType: 'image/png' }], endpoint: 'generations' }, isError: false }, - { - kind: 'assistant_text', - text: '图片已生成。' - } + { kind: 'assistant_text', text: '图片已生成。' } ] } ] @@ -2864,90 +4320,75 @@ describe('ClawRuntime', () => { } throw new Error(`unexpected path ${path}`) }) - const send = vi.fn(async () => ({ messageId: 'om_sent' })) - const addReaction = vi.fn(async () => 'rc_image_1') const runtime = createClawRuntime({ store: store as never, - runtimeRequest, - logError: () => undefined + runtimeRequest: runtimeRequest as never, + logError: () => undefined, + createScheduledTaskFromText: vi.fn(async () => ({ kind: 'noop' as const })) }) - ;(runtime as unknown as { feishuChannels: Map }) - .feishuChannels - .set('channel_1', { send, addReaction }) + const body = JSON.stringify({ + text: '帮我画一张猫的图片', + provider: 'weixin', + channelId: 'channel_weixin', + chatId: 'wx_user_1', + messageId: 'wx_msg_img', + senderId: 'wx_user_1', + senderName: 'Alice' + }) + const req = { + method: 'POST', + url: settings.claw.im.path, + headers: {}, + async *[Symbol.asyncIterator]() { + yield Buffer.from(body) + } + } + let status = 0 + let responseBody = '' + const res = { + writeHead: vi.fn((nextStatus: number) => { + status = nextStatus + }), + end: vi.fn((payload: string) => { + responseBody = payload + }) + } await (runtime as unknown as { - handleFeishuMessage: (channelId: string, message: { - chatId: string - messageId: string - threadId?: string - senderId: string - senderName?: string - chatType: 'p2p' | 'group' - mentionedBot: boolean - mentionAll: boolean - content: string - rawContentType: string - mentions: unknown[] - }) => Promise - }).handleFeishuMessage('channel_1', { - chatId: 'oc_chat_a', - messageId: 'om_inbound', - senderId: 'ou_1', - senderName: 'Alice', - chatType: 'p2p', - mentionedBot: false, - mentionAll: false, - content: '帮我生成一张图片', - rawContentType: 'text', - mentions: [] - }) + handleWebhook: (request: typeof req, response: typeof res) => Promise + }).handleWebhook(req, res) - expect(addReaction).toHaveBeenCalledWith('om_inbound', 'OnIt') - expect(send).toHaveBeenNthCalledWith( - 1, - 'oc_chat_a', - { markdown: '图片已生成。' }, - { replyTo: 'om_inbound', replyInThread: false } - ) - expect(send).toHaveBeenNthCalledWith( - 2, - 'oc_chat_a', - { file: { source: realImagePath, fileName: 'img-20260611000100-abcd.png' } }, - { replyTo: 'om_inbound', replyInThread: false } - ) + expect(status).toBe(200) + const parsed = JSON.parse(responseBody) + expect(parsed).toMatchObject({ ok: true, reply: '图片已生成。' }) + expect(parsed.files).toEqual([ + { + path: realImagePath, + relativePath: '.deepseekgui-images/img-20260611000200-beef.png', + fileName: 'img-20260611000200-beef.png' + } + ]) } finally { await rm(workspaceRoot, { recursive: true, force: true }) } }) - it('returns generated files in the WeChat webhook reply for image requests', async () => { - const workspaceRoot = await mkdtemp(join(tmpdir(), 'deepseek-gui-weixin-image-')) - const imageDir = join(workspaceRoot, '.deepseekgui-images') - const imagePath = join(imageDir, 'img-20260611000200-beef.png') - await mkdir(imageDir, { recursive: true }) - await writeFile(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])) - const realImagePath = await realpath(imagePath) + it('returns send_im_attachment tool output in the WeChat webhook reply', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'deepseek-gui-weixin-im-attachment-')) + const filePath = join(workspaceRoot, 'report.md') + await writeFile(filePath, '# Report\n') + const realFilePath = await realpath(filePath) try { const settings = buildSettings() settings.claw.im.enabled = true settings.claw.im.responseTimeoutMs = 2_000 - settings.agents.kun.imageGeneration = { - enabled: true, - providerId: '', - protocol: 'openai-images', - baseUrl: 'https://images.example.test/v1', - apiKey: 'sk-image', - model: 'test-image-model', - defaultSize: '1024x1024', - quality: 'auto', - timeoutMs: 180000 - } settings.claw.channels = [ buildChannel({ provider: 'weixin' as const, id: 'channel_weixin', label: 'WeChat', threadId: 'thr_wx', + workspaceRoot, conversations: [ buildConversation({ chatId: 'wx_user_1', @@ -2961,9 +4402,9 @@ describe('ClawRuntime', () => { const { store } = mutableSettingsStore(settings) const runtimeRequest = vi.fn(async (_settings, path, init) => { if (path === '/v1/threads/thr_wx/turns' && init?.method === 'POST') { - const body = JSON.parse(init?.body ?? '{}') as { prompt?: string } - expect(body.prompt).toContain('generate_image') - return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_wx_img' }) } + const body = JSON.parse(init?.body ?? '{}') as { imContext?: boolean } + expect(body.imContext).toBe(true) + return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_wx_attachment' }) } } if (path === '/v1/threads/thr_wx' && init?.method === 'GET') { return { @@ -2974,24 +4415,24 @@ describe('ClawRuntime', () => { status: 'idle', turns: [ { - id: 'turn_wx_img', + id: 'turn_wx_attachment', status: 'completed', items: [ { kind: 'tool_result', - toolName: 'generate_image', + toolName: 'send_im_attachment', toolKind: 'tool_call', output: { files: [{ - absolutePath: imagePath, - relativePath: '.deepseekgui-images/img-20260611000200-beef.png', - mimeType: 'image/png' + absolutePath: filePath, + relativePath: 'report.md', + fileName: 'report.md' }], - endpoint: 'generations' + status: 'queued_for_im_attachment_delivery' }, isError: false }, - { kind: 'assistant_text', text: '图片已生成。' } + { kind: 'assistant_text', text: '报告准备好了。' } ] } ] @@ -3007,11 +4448,11 @@ describe('ClawRuntime', () => { createScheduledTaskFromText: vi.fn(async () => ({ kind: 'noop' as const })) }) const body = JSON.stringify({ - text: '帮我画一张猫的图片', + text: '请继续', provider: 'weixin', channelId: 'channel_weixin', chatId: 'wx_user_1', - messageId: 'wx_msg_img', + messageId: 'wx_msg_attachment', senderId: 'wx_user_1', senderName: 'Alice' }) @@ -3040,12 +4481,12 @@ describe('ClawRuntime', () => { expect(status).toBe(200) const parsed = JSON.parse(responseBody) - expect(parsed).toMatchObject({ ok: true, reply: '图片已生成。' }) + expect(parsed).toMatchObject({ ok: true, reply: '报告准备好了。' }) expect(parsed.files).toEqual([ { - path: realImagePath, - relativePath: '.deepseekgui-images/img-20260611000200-beef.png', - fileName: 'img-20260611000200-beef.png' + path: realFilePath, + relativePath: 'report.md', + fileName: 'report.md' } ]) } finally { @@ -3094,8 +4535,7 @@ describe('ClawRuntime', () => { const runtimeRequest = vi.fn(async (_settings, path, init) => { if (path === '/v1/threads/thr_wx_music/turns' && init?.method === 'POST') { const body = JSON.parse(init?.body ?? '{}') as { prompt?: string } - expect(body.prompt).toContain('欢快的人声') - expect(body.prompt).toContain('generate_music') + expectImRuntimePrompt(body.prompt, '欢快的人声') return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_wx_music' }) } } if (path === '/v1/threads/thr_wx_music' && init?.method === 'GET') { @@ -3226,7 +4666,7 @@ describe('ClawRuntime', () => { const runtimeRequest = vi.fn(async (_settings, path, init) => { if (path === '/v1/threads/thr_wx_stale/turns' && init?.method === 'POST') { const body = JSON.parse(init?.body ?? '{}') as { prompt?: string } - expect(body.prompt).toContain('generate_image') + expectImRuntimePrompt(body.prompt, '帮我生成一张图片') return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_current' }) } } if (path === '/v1/threads/thr_wx_stale' && init?.method === 'GET') { @@ -3359,7 +4799,7 @@ describe('ClawRuntime', () => { const runtimeRequest = vi.fn(async (_settings, path, init) => { if (path === '/v1/threads/thr_wx_speech/turns' && init?.method === 'POST') { const body = JSON.parse(init?.body ?? '{}') as { prompt?: string } - expect(body.prompt).toContain('generate_speech') + expectImRuntimePrompt(body.prompt, '帮我生成一段语音旁白') return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_wx_speech' }) } } if (path === '/v1/threads/thr_wx_speech' && init?.method === 'GET') { @@ -3492,8 +4932,7 @@ describe('ClawRuntime', () => { const runtimeRequest = vi.fn(async (_settings, path, init) => { if (path === '/v1/threads/thr_wx_video/turns' && init?.method === 'POST') { const body = JSON.parse(init?.body ?? '{}') as { prompt?: string } - expect(body.prompt).toContain('16:9') - expect(body.prompt).toContain('generate_video') + expectImRuntimePrompt(body.prompt, '16:9') return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_wx_video' }) } } if (path === '/v1/threads/thr_wx_video' && init?.method === 'GET') { @@ -4299,87 +5738,137 @@ describe('ClawRuntime handleFeishuMessage streaming', () => { }) it('still routes through the streaming bridge for prompts that mention sending files', async () => { - // NOTE: The streaming branch does not currently surface `result.files` - // (the streaming reply path uses `streamResult.finalText`, not the - // turn result from `waitForAssistantResult`), so the - // `sendFeishuGeneratedFiles` follow-up does not actually fire in - // the streaming path today. This test pins that behavior: a prompt - // that matches `shouldSendGeneratedFilesForPrompt` (e.g. contains - // "发给我") must still complete the streaming reply without - // crashing. The follow-up file delivery is exercised in the - // feishuStream=false path; see the image/markdown tests above. const { fetchMock, latestHandle } = stubFetchForThreadEvents() + const workspaceRoot = await mkdtemp(join(tmpdir(), 'deepseek-gui-feishu-stream-attachment-')) + const filePath = join(workspaceRoot, 'stream-result.txt') + await writeFile(filePath, 'hello from stream attachment') + const realFilePath = await realpath(filePath) const settings = buildSettings() - settings.claw.im.enabled = true - settings.claw.im.responseTimeoutMs = 5_000 - // feishuStream is per-channel (default off). Enable it on this - // channel so the streaming path is exercised. - settings.claw.channels = [ - buildChannel({ threadId: 'thr_1', feishuStream: true, conversations: [buildConversation({ localThreadId: 'thr_1' })] }) - ] - const store = { - load: vi.fn(async () => settings), - patch: vi.fn(async () => settings) - } - const runtimeRequest = makeTurnRequest() - const bridge = buildStreamingBridge() - let streamedMessageId = '' - bridge.stream.mockImplementation( - async ( - _to: string, - input: { markdown: (controller: { append: (c: string) => Promise; setContent: (s: string) => Promise; messageId: string }) => Promise } - ) => { - const ctrl = { - messageId: 'om_stream_files', - append: vi.fn(async (_chunk: string) => undefined), - setContent: vi.fn(async (_full: string) => undefined) - } - setTimeout(() => { - const h = latestHandle() - if (!h) return - h.emit({ seq: 1, kind: 'assistant_text_delta', turnId: 'turn_1', item: { text: '好的' } }) - h.emit({ seq: 2, kind: 'turn_completed', turnId: 'turn_1' }) - }, 0) - await input.markdown(ctrl) - streamedMessageId = ctrl.messageId - return { messageId: ctrl.messageId } + try { + settings.claw.im.enabled = true + settings.claw.im.responseTimeoutMs = 5_000 + // feishuStream is per-channel (default off). Enable it on this + // channel so the streaming path is exercised. + settings.claw.channels = [ + buildChannel({ + threadId: 'thr_1', + workspaceRoot, + feishuStream: true, + conversations: [buildConversation({ localThreadId: 'thr_1', workspaceRoot })] + }) + ] + const store = { + load: vi.fn(async () => settings), + patch: vi.fn(async () => settings) } - ) - const runtime = createClawRuntime({ - store: store as never, - runtimeRequest, - logError: () => undefined - }) - patchBridge(runtime, 'channel_1', bridge) + const runtimeRequest: RuntimeRequestFn = vi.fn(async (_settings, path, init) => { + if (path === '/v1/threads/thr_1/turns') { + return { + ok: true, + status: 202, + body: JSON.stringify({ threadId: 'thr_1', turnId: 'turn_1' }) + } + } + if (path === '/v1/threads/thr_1' && init?.method === 'GET') { + return { + ok: true, + status: 200, + body: JSON.stringify({ + id: 'thr_1', + status: 'idle', + turns: [ + { + id: 'turn_1', + status: 'completed', + items: [ + { + kind: 'tool_result', + toolName: 'send_im_attachment', + toolKind: 'tool_call', + output: { + files: [{ + absolutePath: filePath, + relativePath: 'stream-result.txt', + fileName: 'stream-result.txt' + }], + status: 'queued_for_im_attachment_delivery' + }, + isError: false + }, + { kind: 'assistant_text', text: '好的' } + ] + } + ] + }) + } + } + throw new Error(`unexpected path ${path}`) + }) as unknown as RuntimeRequestFn + const bridge = buildStreamingBridge() + let streamedMessageId = '' + bridge.stream.mockImplementation( + async ( + _to: string, + input: { markdown: (controller: { append: (c: string) => Promise; setContent: (s: string) => Promise; messageId: string }) => Promise } + ) => { + const ctrl = { + messageId: 'om_stream_files', + append: vi.fn(async (_chunk: string) => undefined), + setContent: vi.fn(async (_full: string) => undefined) + } + setTimeout(() => { + const h = latestHandle() + if (!h) return + h.emit({ seq: 1, kind: 'assistant_text_delta', turnId: 'turn_1', item: { text: '好的' } }) + h.emit({ seq: 2, kind: 'turn_completed', turnId: 'turn_1' }) + }, 0) + await input.markdown(ctrl) + streamedMessageId = ctrl.messageId + return { messageId: ctrl.messageId } + } + ) + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest, + logError: () => undefined + }) + patchBridge(runtime, 'channel_1', bridge) - // "把今天的笔记发给我" — shouldSendGeneratedFilesForPrompt returns true. - await (runtime as unknown as { - handleFeishuMessage: (channelId: string, message: FeishuMessage) => Promise - }).handleFeishuMessage('channel_1', { - chatId: 'oc_chat_a', - messageId: 'om_inbound_files', - senderId: 'ou_1', - senderName: 'Alice', - chatType: 'p2p', - mentionedBot: false, - mentionAll: false, - content: '把今天的笔记发给我', - rawContentType: 'text', - mentions: [] - }) + // "生成一张图片" — shouldSendGeneratedFilesForPrompt returns true + // without taking the direct existing-file shortcut. + await (runtime as unknown as { + handleFeishuMessage: (channelId: string, message: FeishuMessage) => Promise + }).handleFeishuMessage('channel_1', { + chatId: 'oc_chat_a', + messageId: 'om_inbound_files', + senderId: 'ou_1', + senderName: 'Alice', + chatType: 'p2p', + mentionedBot: false, + mentionAll: false, + content: '生成一张图片', + rawContentType: 'text', + mentions: [] + }) - // The streaming card was finalized (the messageId is recorded by - // the bridge mock). - expect(streamedMessageId).toBe('om_stream_files') - expect(bridge.stream).toHaveBeenCalledTimes(1) - // The SSE event stream was opened (proves the streaming branch ran - // end-to-end without falling back to the polling path). - expect(fetchMock).toHaveBeenCalledTimes(1) - // The streaming card is the single user-visible reply. No - // follow-up one-shot text message is sent. The streaming result - // also does not currently carry a `files` payload, so no file - // attachment is dispatched (see the comment above). - expect(bridge.send).not.toHaveBeenCalled() + // The streaming card was finalized (the messageId is recorded by + // the bridge mock). + expect(streamedMessageId).toBe('om_stream_files') + expect(bridge.stream).toHaveBeenCalledTimes(1) + // The SSE event stream was opened (proves the streaming branch ran + // end-to-end without falling back to the polling path). + expect(fetchMock).toHaveBeenCalledTimes(1) + // Text stays in the streaming card; attachment delivery is sent as + // a follow-up file message. + expect(bridge.send).toHaveBeenCalledTimes(1) + expect(bridge.send).toHaveBeenCalledWith( + 'oc_chat_a', + { file: { source: realFilePath, fileName: 'stream-result.txt' } }, + { replyTo: 'om_inbound_files', replyInThread: false } + ) + } finally { + await rm(workspaceRoot, { recursive: true, force: true }) + } }) it('does not touch FeishuStreamer for non-feishu providers (weixin unchanged)', async () => { diff --git a/src/main/claw-runtime.ts b/src/main/claw-runtime.ts index 17f14aa10..20bb5bcc1 100644 --- a/src/main/claw-runtime.ts +++ b/src/main/claw-runtime.ts @@ -51,7 +51,6 @@ import { feishuSenderLabel, finalAssistantReplyText, formatFeishuMirrorText, - imCompletionReplyForPush, isRunningStatus, IM_COMPLETED_NO_TEXT_REPLY, IM_PROCESSING_ACK, @@ -72,6 +71,7 @@ import { type RunPromptOptions, type ThreadDetailJson, type ThreadRecordJson, + createDeferredCloseHandle, type SseSubscriber, subscribeRuntimeThreadEvents } from './claw-runtime-helpers' @@ -110,6 +110,24 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } +function cdataText(value: string): string { + return value.replace(/\]\]>/g, ']]]]>') +} + +function buildImRuntimePrompt(prompt: string): string { + return [ + '', + 'remote_im', + 'false', + 'The user is on a remote IM channel and cannot answer GUI prompts. Do not ask for structured GUI input or wait for GUI confirmation. If information is missing, state your assumption and continue, or ask in the final reply so the user can answer in the next IM message.', + '', + '', + '' + ].join('\n') +} + function fallbackWeixinRemoteSession( payload: Record, senderLabel: string @@ -177,12 +195,24 @@ function isChineseLocale(settings: AppSettingsV1): boolean { return settings.locale.toLowerCase().startsWith('zh') } -function currentImModel(settings: AppSettingsV1, channel?: ClawImChannelV1): string { - return channel?.model?.trim() || settings.claw.im.model.trim() || DEFAULT_CLAW_MODEL +function currentImModel( + settings: AppSettingsV1, + channel?: ClawImChannelV1, + conversation?: ClawImConversationV1 +): string { + return conversation?.model?.trim() || + channel?.model?.trim() || + settings.claw.im.model.trim() || + DEFAULT_CLAW_MODEL } -function currentImProviderId(settings: AppSettingsV1, channel?: ClawImChannelV1): string { - return channel?.providerId?.trim() || +function currentImProviderId( + settings: AppSettingsV1, + channel?: ClawImChannelV1, + conversation?: ClawImConversationV1 +): string { + return conversation?.providerId?.trim() || + channel?.providerId?.trim() || settings.claw.im.providerId?.trim() || getKunRuntimeSettings(settings).providerId.trim() || DEFAULT_MODEL_PROVIDER_ID @@ -216,35 +246,29 @@ function findImProvider(settings: AppSettingsV1, value: string): ModelProviderPr providers.find((provider) => provider.name.trim().toLowerCase() === query.toLowerCase()) } -function currentImProvider(settings: AppSettingsV1, channel?: ClawImChannelV1): ModelProviderProfileV1 { +function currentImProvider( + settings: AppSettingsV1, + channel?: ClawImChannelV1, + conversation?: ClawImConversationV1 +): ModelProviderProfileV1 { const providers = getModelProviderSettings(settings).providers - const providerId = currentImProviderId(settings, channel) + const providerId = currentImProviderId(settings, channel, conversation) return providers.find((provider) => provider.id === providerId) ?? providers.find((provider) => provider.id === DEFAULT_MODEL_PROVIDER_ID) ?? providers[0] } -function resolveImModelAlias(value: string): string { - const normalized = value.trim().toLowerCase() - if (normalized === '自动') return 'auto' - if (normalized === 'pro') return 'deepseek-v4-pro' - if (normalized === 'flash') return 'deepseek-v4-flash' - return value.trim() -} - -function findProviderModel(models: readonly string[], value: string): string | undefined { - const requested = resolveImModelAlias(value) - if (!requested) return undefined - if (requested.toLowerCase() === 'auto') return 'auto' - return models.find((model) => model === requested) ?? - models.find((model) => model.toLowerCase() === requested.toLowerCase()) -} - function firstProviderModel(settings: AppSettingsV1, providerId: string): string { const provider = findImProvider(settings, providerId) return provider ? providerTextModels(settings, provider)[0] ?? DEFAULT_CLAW_MODEL : DEFAULT_CLAW_MODEL } +function validProviderModel(settings: AppSettingsV1, provider: ModelProviderProfileV1, model: string): string | undefined { + const trimmed = model.trim() + if (!trimmed || trimmed === DEFAULT_CLAW_MODEL) return undefined + return providerTextModels(settings, provider).find((item) => item === trimmed) +} + function settingsWithImModelProvider( settings: AppSettingsV1, providerId: string | undefined, @@ -252,8 +276,12 @@ function settingsWithImModelProvider( ): AppSettingsV1 { const trimmedProviderId = providerId?.trim() if (!trimmedProviderId) return settings - const resolvedModel = model.trim() && model.trim() !== DEFAULT_CLAW_MODEL - ? model.trim() + const provider = findImProvider(settings, trimmedProviderId) + const requestedModel = model.trim() + const resolvedModel = requestedModel && requestedModel !== DEFAULT_CLAW_MODEL + ? provider + ? validProviderModel(settings, provider, requestedModel) ?? firstProviderModel(settings, trimmedProviderId) + : requestedModel : firstProviderModel(settings, trimmedProviderId) return { ...settings, @@ -280,109 +308,564 @@ function imCommandHelpText(settings: AppSettingsV1): string { 'Claw IM 命令:', '- `/help`:查看命令帮助', '- `/new`:当前 IM 连接开启新话题', - '- `/provider`:查看已加载的模型供应商', - '- `/provider `:切换当前 IM 连接供应商', - '- `/model`:查看当前供应商可用模型', - '- `/model `:切换当前 IM 连接模型', - '也支持 `-new`、`-help`、`-provider minimax`、`-model MiniMax-M3` 这种写法。' + '- `/clear`:等同于 `/new`,当前 IM 连接开启新话题', + '- `/stop`:停止当前 Kun 会话里正在运行的任务', + '- `/pwd`:查看当前 Kun 会话工作目录本地路径', + '- `/usage`:查看当前 Kun 会话 token 消耗、供应商和模型', + '- `/list-skills`:查看当前 Kun 可用技能', + '- `/list-mcp`:查看当前 Kun MCP 服务器', + '- `/list-goal`:查看当前 Kun 会话目标', + '- `/goal <目标>`:设置当前 Kun 会话目标', + '- `/list-threads`:列出最近的 Kun 会话', + '- `/current`:查看当前 IM 会话连接的 Kun 会话', + '- `/switch <序号|thread id>`:切换当前 IM 会话到指定 Kun 会话', + '- `/list-model`:查看所有可用文本模型', + '- `/model <序号>`:按 `/list-model` 列出的序号切换当前 IM 连接模型', + '命令前缀可以从 `/` 改成 `-`,例如 `-new`、`-list-threads`、`-switch 2`。' ].join('\n') } return [ 'Claw IM commands:', '- `/help`: show command help', '- `/new`: start a new topic for this IM connection', - '- `/provider`: list loaded model providers', - '- `/provider `: switch the provider for this IM connection', - '- `/model`: list models for the current provider', - '- `/model `: switch the model for this IM connection', - '`-new`, `-help`, `-provider minimax`, and `-model MiniMax-M3` are supported too.' + '- `/clear`: same as `/new`, start a new topic for this IM connection', + '- `/stop`: stop the running task in the current Kun conversation', + '- `/pwd`: show the local workspace path for the current Kun conversation', + '- `/usage`: show token usage plus provider/model for the current Kun conversation', + '- `/list-skills`: list available Kun skills', + '- `/list-mcp`: list Kun MCP servers', + '- `/list-goal`: show the current Kun conversation goal', + '- `/goal `: set the current Kun conversation goal', + '- `/list-threads`: list recent Kun conversations', + '- `/current`: show the Kun conversation connected to this IM chat', + '- `/switch `: switch this IM chat to a Kun conversation', + '- `/list-model`: list all available text models', + '- `/model `: switch this IM connection to a model listed by `/list-model`', + 'The command prefix can be changed from `/` to `-`, for example `-new`, `-list-threads`, or `-switch 2`.' ].join('\n') } -function imProviderListText(settings: AppSettingsV1, channel?: ClawImChannelV1): string { - const providers = getModelProviderSettings(settings).providers - const currentProviderId = currentImProviderId(settings, channel) - const rows = providers.map((provider) => { - const marker = provider.id === currentProviderId ? '*' : '-' - const modelCount = providerTextModels(settings, provider).length - const keyStatus = provider.apiKey.trim() - ? (isChineseLocale(settings) ? '已配置 API Key' : 'API key set') - : (isChineseLocale(settings) ? '未配置 API Key' : 'no API key') - return `${marker} \`${provider.id}\` ${providerLabel(provider)} · ${modelCount} models · ${keyStatus}` +function imModelListText( + settings: AppSettingsV1, + channel?: ClawImChannelV1, + conversation?: ClawImConversationV1 +): string { + const current = currentImModelResolution(settings, channel, conversation) + const currentProviderId = current.provider.id + const currentModel = current.model + const rows = listImModelOptions(settings).map((option, index) => { + const { provider, model } = option + const marker = provider.id === currentProviderId && model === currentModel ? '*' : '-' + return `${marker} ${index + 1}. \`${model}\` · provider \`${provider.id}\`` }) if (isChineseLocale(settings)) { return [ `当前供应商:\`${currentProviderId}\`。`, - '已加载供应商:', - ...rows, - '切换供应商:`/provider `。切换后可用 `/model` 查看该供应商模型。' + `当前模型:\`${currentModel}\`。`, + ...(rows.length > 0 + ? ['可用模型:', ...rows, '切换模型:`/model <序号>`。'] + : ['还没有可用的文本模型,请先在设置里为供应商配置模型。']) ].join('\n') } return [ `Current provider: \`${currentProviderId}\`.`, - 'Loaded providers:', + `Current model: \`${currentModel}\`.`, + ...(rows.length > 0 + ? ['Available models:', ...rows, 'Switch model with `/model `.'] + : ['No usable text models are available yet. Add models for a provider in Settings first.']) + ].join('\n') +} + +type ImModelResolution = { + provider: ModelProviderProfileV1 + model: string +} + +function listImModelOptions(settings: AppSettingsV1): ImModelResolution[] { + return getModelProviderSettings(settings).providers.flatMap((provider) => + providerTextModels(settings, provider).map((model) => ({ provider, model })) + ) +} + +function resolveImModelByIndex(settings: AppSettingsV1, value: string): ImModelResolution | undefined { + const raw = value.trim() + if (!/^\d+$/.test(raw)) return undefined + const index = Number.parseInt(raw, 10) + if (!Number.isSafeInteger(index) || index < 1) return undefined + return listImModelOptions(settings)[index - 1] +} + +function imModelCommandHint(settings: AppSettingsV1, value: string): string { + const count = listImModelOptions(settings).length + return imKunErrorText(settings, isChineseLocale(settings) + ? `无效的模型序号 \`${value}\`。${count > 0 ? '发送 `/list-model` 查看可用序号。' : '还没有可用的文本模型。'}` + : `Invalid model number \`${value}\`. ${count > 0 ? 'Send `/list-model` to see available numbers.' : 'No usable text models are available yet.'}`) +} + +function imModelChangedText(settings: AppSettingsV1, providerId: string, model: string): string { + return isChineseLocale(settings) + ? `Claw IM 模型已切换到 \`${model}\`,供应商为 \`${providerId}\`。` + : `Claw IM model switched to \`${model}\` with provider \`${providerId}\`.` +} + +function currentImModelResolution( + settings: AppSettingsV1, + channel?: ClawImChannelV1, + conversation?: ClawImConversationV1 +): ImModelResolution { + const provider = currentImProvider(settings, channel, conversation) + const requestedModel = currentImModel(settings, channel, conversation) + const model = validProviderModel(settings, provider, requestedModel) ?? firstProviderModel(settings, provider.id) + return { provider, model } +} + +function imNewTopicText(settings: AppSettingsV1): string { + return isChineseLocale(settings) + ? '新话题已开启。下一条消息会创建新的本地会话。' + : 'Started a new topic. The next message will create a fresh local conversation.' +} + +function imKunErrorText(_settings: AppSettingsV1, message: string): string { + return imKunSystemText(message) +} + +function imKunSystemText(message: string): string { + const trimmed = message.trim() + if (trimmed.startsWith('[Kun]')) return trimmed + if (trimmed.startsWith('Kun:')) return `[Kun] ${trimmed.slice('Kun:'.length).trim()}` + return `[Kun] ${trimmed}` +} + +type ImSkillSummary = { + id: string + name: string + description?: string + source?: string +} + +type ImMcpServerSummary = { + id: string + enabled: boolean + available: boolean + status: string + transport?: string + toolCount?: number + lastError?: string +} + +type ImThreadUsageSummary = { + promptTokens: number + completionTokens: number + totalTokens: number + cachedTokens: number + cacheHitTokens: number + cacheMissTokens: number + turns: number + costUsd?: number + costCny?: number +} + +type ImGoalSummary = { + objective: string + status?: string + tokensUsed?: number +} + +function parseSkillsResponse(body: string): { enabled: boolean; skills: ImSkillSummary[] } { + const parsed = parseJsonObject(body) + const skills = Array.isArray(parsed?.skills) ? parsed.skills : [] + return { + enabled: parsed?.enabled === true, + skills: skills + .filter((item): item is Record => typeof item === 'object' && item !== null && !Array.isArray(item)) + .map((item): ImSkillSummary => ({ + id: asString(item.id), + name: asString(item.name), + description: asString(item.description), + source: asString(item.source) + })) + .filter((skill) => skill.id || skill.name) + } +} + +function imSkillListText(settings: AppSettingsV1, enabled: boolean, skills: readonly ImSkillSummary[]): string { + if (!enabled) { + return isChineseLocale(settings) + ? 'Kun 技能当前未启用。' + : 'Kun skills are currently disabled.' + } + if (skills.length === 0) { + return isChineseLocale(settings) + ? '当前没有发现可用技能。' + : 'No available skills were discovered.' + } + const rows = skills.slice(0, 30).map((skill, index) => { + const name = skill.name || skill.id + const source = skill.source ? ` · ${skill.source}` : '' + const description = skill.description ? `:${skill.description}` : '' + return `- ${index + 1}. \`${skill.id || name}\` ${name}${source}${description}` + }) + const extra = skills.length > rows.length + ? (isChineseLocale(settings) ? `还有 ${skills.length - rows.length} 个技能未显示。` : `${skills.length - rows.length} more skills not shown.`) + : '' + return [ + isChineseLocale(settings) ? '可用 Kun 技能:' : 'Available Kun skills:', ...rows, - 'Switch provider with `/provider `. Use `/model` after switching to list its models.' + ...(extra ? [extra] : []) ].join('\n') } -function imProviderCommandHint(settings: AppSettingsV1, value: string): string { +function parseMcpResponse(body: string): ImMcpServerSummary[] { + const parsed = parseJsonObject(body) + const servers = Array.isArray(parsed?.mcpServers) ? parsed.mcpServers : [] + return servers + .filter((item): item is Record => typeof item === 'object' && item !== null && !Array.isArray(item)) + .map((item): ImMcpServerSummary => ({ + id: asString(item.id), + enabled: item.enabled === true, + available: item.available === true, + status: asString(item.status), + transport: asString(item.transport), + toolCount: typeof item.toolCount === 'number' && Number.isFinite(item.toolCount) + ? item.toolCount + : undefined, + lastError: asString(item.lastError) + })) + .filter((server) => server.id) +} + +function numberValue(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} + +function optionalNumberValue(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function parseThreadUsageResponse(body: string, threadId: string): ImThreadUsageSummary { + const parsed = parseJsonObject(body) + const buckets = Array.isArray(parsed?.buckets) ? parsed.buckets : [] + const bucket = buckets.find((item): item is Record => { + return typeof item === 'object' && + item !== null && + !Array.isArray(item) && + asString((item as Record).thread_id) === threadId + }) + if (!bucket) { + return { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cachedTokens: 0, + cacheHitTokens: 0, + cacheMissTokens: 0, + turns: 0 + } + } + return { + promptTokens: numberValue(bucket.input_tokens), + completionTokens: numberValue(bucket.output_tokens), + totalTokens: numberValue(bucket.total_tokens), + cachedTokens: numberValue(bucket.cached_tokens), + cacheHitTokens: numberValue(bucket.cache_hit_tokens) || numberValue(bucket.cached_tokens), + cacheMissTokens: numberValue(bucket.cache_miss_tokens), + turns: numberValue(bucket.turns), + costUsd: optionalNumberValue(bucket.cost_usd), + costCny: optionalNumberValue(bucket.cost_cny) + } +} + +function imMcpListText(settings: AppSettingsV1, servers: readonly ImMcpServerSummary[]): string { + if (servers.length === 0) { + return isChineseLocale(settings) + ? '当前没有配置 Kun MCP 服务器。' + : 'No Kun MCP servers are configured.' + } + const rows = servers.map((server, index) => { + const state = server.available + ? (isChineseLocale(settings) ? '可用' : 'available') + : server.enabled + ? (server.status || (isChineseLocale(settings) ? '不可用' : 'unavailable')) + : (isChineseLocale(settings) ? '已禁用' : 'disabled') + const transport = server.transport ? ` · ${server.transport}` : '' + const tools = typeof server.toolCount === 'number' ? ` · ${server.toolCount} tools` : '' + const error = server.lastError ? ` · ${server.lastError}` : '' + return `- ${index + 1}. \`${server.id}\` ${state}${transport}${tools}${error}` + }) + return [ + isChineseLocale(settings) ? 'Kun MCP 服务器:' : 'Kun MCP servers:', + ...rows + ].join('\n') +} + +function imWorkspaceText(settings: AppSettingsV1, threadId: string, workspace: string): string { return isChineseLocale(settings) - ? `没有找到供应商 \`${value}\`。发送 \`/provider\` 查看已加载供应商。` - : `Provider \`${value}\` was not found. Send \`/provider\` to list loaded providers.` + ? `当前 Kun 会话 \`${threadId}\` 的工作目录:\n\`${workspace}\`` + : `Workspace for current Kun conversation \`${threadId}\`:\n\`${workspace}\`` +} + +function imWorkspaceMissingText(settings: AppSettingsV1, threadId: string): string { + return imKunErrorText(settings, isChineseLocale(settings) + ? `没有读取到当前 Kun 会话 \`${threadId}\` 的工作目录。` + : `Could not read the workspace path for current Kun conversation \`${threadId}\`.`) } -function imProviderChangedText( +function imMarkdownLines(lines: string[]): string { + return lines.join(' \n') +} + +function imUsageText( settings: AppSettingsV1, - provider: ModelProviderProfileV1, - model: string + threadId: string, + usage: ImThreadUsageSummary, + model: ImModelResolution ): string { + const costParts = [ + usage.costUsd !== undefined ? `USD ${usage.costUsd.toFixed(6)}` : '', + usage.costCny !== undefined ? `CNY ${usage.costCny.toFixed(6)}` : '' + ].filter(Boolean) + const costText = costParts.length > 0 ? costParts.join(' · ') : (isChineseLocale(settings) ? '无' : 'none') + if (isChineseLocale(settings)) { + return imMarkdownLines([ + `当前 Kun 会话:\`${threadId}\``, + `供应商:\`${model.provider.id}\``, + `模型:\`${model.model}\``, + `Token 消耗:total ${usage.totalTokens} · input ${usage.promptTokens} · output ${usage.completionTokens}`, + `缓存:cached ${usage.cachedTokens} · hit ${usage.cacheHitTokens} · miss ${usage.cacheMissTokens}`, + `轮次:${usage.turns}`, + `费用:${costText}` + ]) + } + return imMarkdownLines([ + `Current Kun conversation: \`${threadId}\``, + `Provider: \`${model.provider.id}\``, + `Model: \`${model.model}\``, + `Token usage: total ${usage.totalTokens} · input ${usage.promptTokens} · output ${usage.completionTokens}`, + `Cache: cached ${usage.cachedTokens} · hit ${usage.cacheHitTokens} · miss ${usage.cacheMissTokens}`, + `Turns: ${usage.turns}`, + `Cost: ${costText}` + ]) +} + +function parseGoalResponse(body: string): ImGoalSummary | null { + const parsed = parseJsonObject(body) + const goal = typeof parsed?.goal === 'object' && parsed.goal !== null && !Array.isArray(parsed.goal) + ? parsed.goal as Record + : null + if (!goal) return null + const objective = asString(goal.objective) + if (!objective) return null + const tokensUsed = typeof goal.tokensUsed === 'number' && Number.isFinite(goal.tokensUsed) + ? goal.tokensUsed + : undefined + return { + objective, + status: asString(goal.status), + tokensUsed + } +} + +function imNoCurrentThreadText(settings: AppSettingsV1): string { + return imKunErrorText(settings, isChineseLocale(settings) + ? '当前 IM 会话还没有绑定 Kun 会话。先发送普通消息创建会话,或用 `/list-threads` 和 `/switch` 切换到已有会话。' + : 'This IM chat is not connected to a Kun conversation yet. Send a normal message to create one, or use `/list-threads` and `/switch` to pick one.') +} + +function imGoalText(settings: AppSettingsV1, goal: ImGoalSummary | null): string { + if (!goal) { + return isChineseLocale(settings) + ? '当前 Kun 会话还没有设置目标。使用 `/goal <目标>` 设置。' + : 'The current Kun conversation has no goal yet. Set one with `/goal `.' + } + const status = goal.status ? ` · ${goal.status}` : '' + const tokens = typeof goal.tokensUsed === 'number' ? ` · ${goal.tokensUsed} tokens` : '' return isChineseLocale(settings) - ? `当前 IM 供应商已切换到 \`${provider.id}\`,模型为 \`${model}\`。发送 \`/model\` 可查看这个供应商的可用模型。` - : `IM provider switched to \`${provider.id}\`; model is \`${model}\`. Send \`/model\` to list models for this provider.` + ? `当前目标${status}${tokens}:\n${goal.objective}` + : `Current goal${status}${tokens}:\n${goal.objective}` +} + +function imGoalMissingObjectiveText(settings: AppSettingsV1): string { + return imKunErrorText(settings, isChineseLocale(settings) + ? '`/goal` 后面必须带目标内容,例如:`/goal 阅读并总结文档 A`。查看当前目标请用 `/list-goal`。' + : '`/goal` requires an objective, for example: `/goal Read and summarize document A`. Use `/list-goal` to view the current goal.') } -function imModelListText(settings: AppSettingsV1, channel?: ClawImChannelV1): string { - const provider = currentImProvider(settings, channel) - const models = providerTextModels(settings, provider) - const currentModel = currentImModel(settings, channel) - const rows = models.map((model) => `${model === currentModel ? '*' : '-'} \`${model}\``) +function imGoalAlreadyExistsText(settings: AppSettingsV1, goal: ImGoalSummary): string { + return imKunErrorText(settings, isChineseLocale(settings) + ? `当前会话已经有目标,不能重复设置:\n${goal.objective}` + : `This conversation already has a goal, so a new one was not set:\n${goal.objective}`) +} + +function imGoalChangedText(settings: AppSettingsV1, goal: ImGoalSummary | null): string { + if (!goal) { + return isChineseLocale(settings) + ? '目标已更新。' + : 'Goal updated.' + } + return isChineseLocale(settings) + ? `目标已设置:\n${goal.objective}` + : `Goal set:\n${goal.objective}` +} + +function imThreadTitle(thread: ThreadRecordJson): string { + const title = thread.title?.trim() + return title || thread.id +} + +function imThreadTimestamp(thread: ThreadRecordJson): number { + const value = Date.parse(thread.updatedAt ?? thread.createdAt ?? '') + return Number.isFinite(value) ? value : 0 +} + +function isImThreadCandidate( + thread: ThreadRecordJson, + channel: ClawImChannelV1 | undefined, + knownThreadIds: Set +): boolean { + if (knownThreadIds.has(thread.id)) return true + if (!channel) return true + const title = thread.title?.trim() ?? '' + const prefix = `[Claw IM:${channel.label}]` + return title.startsWith(prefix) +} + +function parseListThreadsResponse(body: string): ThreadRecordJson[] { + const parsed = parseJsonObject(body) + const threads = Array.isArray(parsed?.threads) ? parsed.threads : [] + return threads + .filter((item): item is Record => typeof item === 'object' && item !== null && !Array.isArray(item)) + .map((item): ThreadRecordJson => ({ + id: asString(item.id), + title: asString(item.title), + status: asString(item.status), + workspace: asString(item.workspace), + createdAt: asString(item.createdAt), + updatedAt: asString(item.updatedAt) + })) + .filter((thread) => thread.id.trim()) +} + +function imThreadListText( + settings: AppSettingsV1, + threads: readonly ThreadRecordJson[], + currentThreadId: string +): string { + if (threads.length === 0) { + return isChineseLocale(settings) + ? '还没有找到可切换的 Kun 会话。先发送普通消息创建会话,或发送 `/new` 开启新话题。' + : 'No switchable Kun conversations were found yet. Send a normal message to create one, or send `/new` to start a new topic.' + } + const rows = threads.map((thread, index) => { + const marker = thread.id === currentThreadId ? '*' : '-' + const status = thread.status?.trim() ? ` · ${thread.status.trim()}` : '' + return `${marker} ${index + 1}. \`${thread.id}\` ${imThreadTitle(thread)}${status}` + }) if (isChineseLocale(settings)) { return [ - `当前供应商:\`${provider.id}\` ${providerLabel(provider)}`, - `当前模型:\`${currentModel}\`。`, - ...(rows.length > 0 - ? ['可用模型:', ...rows, '切换模型:`/model `。'] - : ['这个供应商还没有可用的文本模型,请先在设置里为它配置模型。']) + currentThreadId ? `当前会话:\`${currentThreadId}\`。` : '当前还没有绑定 Kun 会话。', + '最近 Kun 会话:', + ...rows, + '切换会话:`/switch <序号|thread id>`。新话题:`/new`。' ].join('\n') } return [ - `Current provider: \`${provider.id}\` ${providerLabel(provider)}`, - `Current model: \`${currentModel}\`.`, - ...(rows.length > 0 - ? ['Available models:', ...rows, 'Switch model with `/model `.'] - : ['This provider has no usable text models yet. Add models for it in Settings first.']) + currentThreadId ? `Current conversation: \`${currentThreadId}\`.` : 'No Kun conversation is connected yet.', + 'Recent Kun conversations:', + ...rows, + 'Switch with `/switch `. Start fresh with `/new`.' ].join('\n') } -function imModelCommandHint(settings: AppSettingsV1, provider: ModelProviderProfileV1, value: string): string { - const models = providerTextModels(settings, provider) - const ids = models.map((model) => `\`${model}\``).join(', ') - return isChineseLocale(settings) - ? `供应商 \`${provider.id}\` 下没有找到模型 \`${value}\`。${ids ? `可用模型:${ids}。` : '这个供应商还没有可用的文本模型。'}` - : `Model \`${value}\` was not found for provider \`${provider.id}\`. ${ids ? `Available models: ${ids}.` : 'This provider has no usable text models yet.'}` +function imCurrentThreadText( + settings: AppSettingsV1, + thread: ThreadRecordJson | undefined, + currentThreadId: string, + shared = false +): string { + if (!currentThreadId) { + return imKunErrorText(settings, isChineseLocale(settings) + ? '当前 IM 会话还没有绑定 Kun 会话。发送普通消息会创建一个新会话。' + : 'This IM chat is not connected to a Kun conversation yet. Send a normal message to create one.') + } + if (!thread) { + return imKunErrorText(settings, isChineseLocale(settings) + ? `当前绑定的 Kun 会话是 \`${currentThreadId}\`,但线程列表里暂时没有读取到它。` + : `This IM chat is connected to \`${currentThreadId}\`, but it was not found in the thread list.`) + } + const status = thread.status?.trim() ? ` · ${thread.status.trim()}` : '' + const text = isChineseLocale(settings) + ? `当前 Kun 会话:\`${thread.id}\` ${imThreadTitle(thread)}${status}。` + : `Current Kun conversation: \`${thread.id}\` ${imThreadTitle(thread)}${status}.` + return shared ? `${text}\n\n${imSharedThreadWarningText(settings)}` : text +} + +function resolveImThreadSwitchTarget( + threads: readonly ThreadRecordJson[], + target: string +): ThreadRecordJson | null { + const query = target.trim() + if (!query) return null + const index = Number.parseInt(query, 10) + if (String(index) === query && index >= 1 && index <= threads.length) { + return threads[index - 1] + } + const lowered = query.toLowerCase() + return threads.find((thread) => thread.id === query) ?? + threads.find((thread) => thread.id.toLowerCase() === lowered) ?? + null +} + +function imThreadSwitchNotFoundText(settings: AppSettingsV1, target: string): string { + return imKunErrorText(settings, isChineseLocale(settings) + ? `没有找到可切换的会话 \`${target}\`。发送 \`/list-threads\` 查看最近会话。` + : `Could not find a switchable conversation for \`${target}\`. Send \`/list-threads\` to list recent conversations.`) } -function imModelChangedText(settings: AppSettingsV1, model: string): string { +function imSharedThreadWarningText(settings: AppSettingsV1): string { return isChineseLocale(settings) - ? `Claw IM 模型已切换到 \`${model}\`。` - : `Claw IM model switched to \`${model}\`.` + ? '注意:这个 Kun 会话也被其他 IM 会话持有。Kun 不会对共享会话做 IM 侧并发控制,请不要在多个 IM 里同时对话。' + : 'Note: this Kun conversation is also held by another IM chat. Kun does not add IM-side concurrency control for shared conversations, so avoid chatting into it from multiple IM chats at the same time.' } -function imNewTopicText(settings: AppSettingsV1): string { +function imThreadSwitchedText(settings: AppSettingsV1, thread: ThreadRecordJson, shared: boolean): string { + const text = isChineseLocale(settings) + ? `已切换到 Kun 会话 \`${thread.id}\`:${imThreadTitle(thread)}。后续消息会继续这个上下文。` + : `Switched to Kun conversation \`${thread.id}\`: ${imThreadTitle(thread)}. Future messages will continue that context.` + return shared ? `${text}\n\n${imSharedThreadWarningText(settings)}` : text +} + +function hasOtherImThreadBinding( + settings: AppSettingsV1, + threadId: string, + currentChannelId: string | undefined, + currentConversationId: string | undefined +): boolean { + const targetThreadId = threadId.trim() + if (!targetThreadId) return false + for (const channel of settings.claw.channels) { + if (!channel.enabled) continue + for (const conversation of channel.conversations) { + if (conversation.localThreadId.trim() !== targetThreadId) continue + if (channel.id === currentChannelId && conversation.id === currentConversationId) continue + return true + } + if (channel.threadId.trim() !== targetThreadId) continue + if (channel.id === currentChannelId && !currentConversationId) continue + return true + } + return false +} + +function imStopNoRunningTurnText(settings: AppSettingsV1): string { + return imKunErrorText(settings, isChineseLocale(settings) + ? '当前 Kun 会话没有正在运行的任务。' + : 'The current Kun conversation has no running task.') +} + +function imStopSucceededText(settings: AppSettingsV1, turnId: string): string { return isChineseLocale(settings) - ? '新话题已开启。下一条消息会创建新的本地会话。' - : 'Started a new topic. The next message will create a fresh local conversation.' + ? `Kun 已停止当前任务:\`${turnId}\`。` + : `Kun stopped the current task: \`${turnId}\`.` } /** @@ -592,7 +1075,9 @@ export class ClawRuntime { if (!thread) return { ok: false, message: 'Failed to create thread.' } if (!existingThreadId) patchThreadTitle(thread) - const runtimePrompt = buildClawRuntimePrompt(runtimeSettings, options.prompt, { channel: options.channel }) + const runtimePrompt = options.source === 'im' + ? buildImRuntimePrompt(options.prompt) + : buildClawRuntimePrompt(runtimeSettings, options.prompt, { channel: options.channel }) const displayText = options.displayText?.trim() || parseClawUserPromptForDisplay(options.prompt).text const turnBody: Record = { prompt: runtimePrompt, @@ -606,6 +1091,7 @@ export class ClawRuntime { // IM turns follow the same policy the user picked for the GUI. if (options.source === 'im') { turnBody.disableUserInput = true + turnBody.imContext = true turnBody.approvalPolicy = runtimeSettings.agents.kun.approvalPolicy turnBody.sandboxMode = runtimeSettings.agents.kun.sandboxMode } @@ -759,10 +1245,8 @@ export class ClawRuntime { // setup; if the setup itself throws (e.g. no base URL) we log via // deps.logError and continue with a no-op close. The streamer will // still rely on its own responseTimeoutMs abort as a backstop. - const setup = this.subscribeSse(settings, threadId, streamer, signal) - let close = (): void => undefined - void setup.then( - (handle) => { close = handle.close }, + return createDeferredCloseHandle( + this.subscribeSse(settings, threadId, streamer, signal), (error) => { this.deps.logError('claw-feishu-stream', 'SSE subscription setup failed', { message: error instanceof Error ? error.message : String(error), @@ -770,7 +1254,6 @@ export class ClawRuntime { }) } ) - return { close: () => close() } } } @@ -889,11 +1372,29 @@ export class ClawRuntime { ) return } + const files = + outcome.status === 'completed' + ? await this.resolveImGeneratedFiles(outcome.files, input.workspaceRoot, { + purpose: 'agent-file-delayed-resolve', + channelId: channel.id, + threadId: input.threadId, + turnId, + chatId: input.remoteSession?.chatId + }) + : [] const body = outcome.status === 'completed' - ? outcome.text.trim() || imCompletionReplyForPush(outcome.files) + ? replyTextForGeneratedFiles(outcome.text.trim() || IM_COMPLETED_NO_TEXT_REPLY, files) : `❌ 任务未完成:${outcome.error || outcome.status}` await this.pushImMessage(channel, input.remoteSession, body) + if (outcome.status === 'completed') { + await this.pushImGeneratedFiles(channel, input.remoteSession, files, { + channelId: channel.id, + threadId: input.threadId, + turnId, + chatId: input.remoteSession?.chatId + }) + } } catch (error) { this.deps.logError('claw-im', 'Failed to push a delayed agent result.', { message: errorMessage(error), @@ -906,6 +1407,62 @@ export class ClawRuntime { })() } + /** Pushes generated files for a delayed IM turn that finished after the response window. */ + private async pushImGeneratedFiles( + channel: ClawImChannelV1, + remoteSession: Pick | undefined, + files: readonly ClawGeneratedFileV1[], + context: Record + ): Promise { + if (files.length === 0) return + const to = remoteSession?.chatId.trim() || channel.remoteSession?.chatId.trim() || '' + if (!to) return + if (channel.provider === 'weixin') { + const credential = channel.platformCredential + if (credential?.kind !== 'weixin' || !credential.accountId.trim() || !this.deps.sendWeixinBridgeMessage) return + const result = await this.deps.sendWeixinBridgeMessage({ + accountId: credential.accountId, + to, + files: files.map((file) => ({ path: file.path, fileName: file.fileName })) + }) + if (!result.ok) { + this.deps.logError('claw-weixin', 'Failed to push delayed generated files over the WeChat bridge.', { + ...context, + channelId: channel.id, + message: result.message + }) + } + return + } + if (channel.provider === 'feishu') { + const bridge = this.feishuChannels.get(channel.id) + if (!bridge) return + await this.sendFeishuGeneratedFiles(bridge, to, files, {}, { + ...context, + channelId: channel.id, + chatId: to, + purpose: 'agent-file-delayed' + }) + return + } + if (channel.provider === 'telegram') { + if (!this.deps.telegramRuntime) return + for (const file of files) { + const result = await this.deps.telegramRuntime.sendFile(channel.id, to, file.path, file.fileName) + if (!result.ok) { + this.deps.logError('claw-telegram', 'Failed to push delayed generated file over Telegram.', { + ...context, + channelId: channel.id, + chatId: to, + filePath: file.path, + fileName: file.fileName, + message: result.message + }) + } + } + } + } + /** Pushes a standalone bridge message to the sender of an inbound IM. */ private async pushImMessage( channel: ClawImChannelV1, @@ -1049,53 +1606,311 @@ export class ClawRuntime { }) } - private async setIncomingImModel(channel: ClawImChannelV1 | undefined, model: string): Promise { + private async setIncomingImProvider( + channel: ClawImChannelV1 | undefined, + conversation: ClawImConversationV1 | undefined, + remoteSession: Pick | undefined, + providerId: string, + model: string + ): Promise { if (!channel) { - await this.deps.store.patch({ claw: { im: { model } } }) + await this.deps.store.patch({ claw: { im: { providerId, model } } }) return } const currentSettings = await this.deps.store.load() const now = new Date().toISOString() await this.deps.store.patch({ claw: { - channels: currentSettings.claw.channels.map((item) => - item.id === channel.id + channels: currentSettings.claw.channels.map((item) => { + if (item.id !== channel.id) return item + const currentConversation = conversation + ? item.conversations.find((entry) => entry.id === conversation.id) + : remoteSession + ? this.findChannelConversation(item, remoteSession) + : undefined + const nextConversation: ClawImConversationV1 | null = remoteSession && !currentConversation ? { - ...item, + id: randomUUID(), + chatId: remoteSession.chatId, + remoteThreadId: remoteSession.threadId, + latestMessageId: remoteSession.messageId, + senderId: remoteSession.senderId, + senderName: remoteSession.senderName, + localThreadId: '', + workspaceRoot: this.resolveConversationWorkspaceRoot(currentSettings, item, remoteSession), + providerId, model, + createdAt: now, updatedAt: now } - : item - ) + : null + return { + ...item, + providerId: remoteSession ? item.providerId : providerId, + model: remoteSession ? item.model : model, + conversations: currentConversation + ? item.conversations.map((entry) => + entry.id === currentConversation.id + ? { + ...entry, + latestMessageId: remoteSession?.messageId || entry.latestMessageId, + senderId: remoteSession?.senderId || entry.senderId, + senderName: remoteSession?.senderName || entry.senderName, + providerId, + model, + updatedAt: now + } + : entry + ) + : nextConversation + ? [...item.conversations, nextConversation] + : item.conversations, + updatedAt: now + } + }) } }) } - private async setIncomingImProvider( + private currentIncomingImThreadId( channel: ClawImChannelV1 | undefined, - providerId: string, - model: string - ): Promise { - if (!channel) { - await this.deps.store.patch({ claw: { im: { providerId, model } } }) - return + conversation: ClawImConversationV1 | undefined, + remoteSession?: Pick | undefined + ): string { + const legacyChannelThreadId = remoteSession && !conversation && (channel?.conversations.length ?? 0) === 0 + ? channel?.threadId.trim() + : '' + return conversation?.localThreadId.trim() || + legacyChannelThreadId || + (remoteSession ? '' : channel?.threadId.trim()) || + '' + } + + private async listIncomingImThreads( + settings: AppSettingsV1, + limit: number + ): Promise { + const response = await this.deps.runtimeRequest( + settings, + `/v1/threads?limit=${encodeURIComponent(String(limit))}`, + { method: 'GET' } + ) + if (!response.ok) { + throw new Error(runtimeErrorMessage(response, 'Failed to list threads.')) + } + return parseListThreadsResponse(response.body) + .sort((a, b) => imThreadTimestamp(b) - imThreadTimestamp(a)) + .slice(0, limit) + } + + private async listIncomingImSwitchableThreads( + settings: AppSettingsV1, + channel: ClawImChannelV1 | undefined + ): Promise { + const response = await this.deps.runtimeRequest( + settings, + '/v1/threads?limit=50', + { method: 'GET' } + ) + if (!response.ok) { + throw new Error(runtimeErrorMessage(response, 'Failed to list threads.')) + } + const knownThreadIds = new Set() + if (channel?.threadId.trim()) knownThreadIds.add(channel.threadId.trim()) + for (const conversation of channel?.conversations ?? []) { + if (conversation.localThreadId.trim()) knownThreadIds.add(conversation.localThreadId.trim()) + } + return parseListThreadsResponse(response.body) + .filter((thread) => isImThreadCandidate(thread, channel, knownThreadIds)) + .sort((a, b) => imThreadTimestamp(b) - imThreadTimestamp(a)) + } + + private async listIncomingImSkills(settings: AppSettingsV1): Promise<{ enabled: boolean; skills: ImSkillSummary[] }> { + const response = await this.deps.runtimeRequest( + settings, + '/v1/skills', + { method: 'GET' } + ) + if (!response.ok) { + throw new Error(runtimeErrorMessage(response, 'Failed to list skills.')) + } + return parseSkillsResponse(response.body) + } + + private async listIncomingImMcpServers(settings: AppSettingsV1): Promise { + const response = await this.deps.runtimeRequest( + settings, + '/v1/runtime/tools', + { method: 'GET' } + ) + if (!response.ok) { + throw new Error(runtimeErrorMessage(response, 'Failed to list MCP servers.')) + } + return parseMcpResponse(response.body) + } + + private async getIncomingImGoal(settings: AppSettingsV1, threadId: string): Promise { + const response = await this.deps.runtimeRequest( + settings, + `/v1/threads/${encodeURIComponent(threadId)}/goal`, + { method: 'GET' } + ) + if (!response.ok) { + throw new Error(runtimeErrorMessage(response, 'Failed to read goal.')) + } + return parseGoalResponse(response.body) + } + + private async setIncomingImGoal( + settings: AppSettingsV1, + threadId: string, + objective: string + ): Promise { + const response = await this.deps.runtimeRequest( + settings, + `/v1/threads/${encodeURIComponent(threadId)}/goal`, + { + method: 'POST', + body: JSON.stringify({ objective }) + } + ) + if (!response.ok) { + throw new Error(runtimeErrorMessage(response, 'Failed to set goal.')) + } + return parseGoalResponse(response.body) + } + + private async getIncomingImThreadDetail(settings: AppSettingsV1, threadId: string): Promise { + const response = await this.deps.runtimeRequest( + settings, + `/v1/threads/${encodeURIComponent(threadId)}`, + { method: 'GET' } + ) + if (!response.ok) { + throw new Error(runtimeErrorMessage(response, 'Failed to read thread.')) + } + return JSON.parse(response.body) as ThreadDetailJson + } + + private async getIncomingImThreadUsage(settings: AppSettingsV1, threadId: string): Promise { + const response = await this.deps.runtimeRequest( + settings, + `/v1/usage?group_by=thread&thread_id=${encodeURIComponent(threadId)}`, + { method: 'GET' } + ) + if (!response.ok) { + throw new Error(runtimeErrorMessage(response, 'Failed to read usage.')) + } + return parseThreadUsageResponse(response.body, threadId) + } + + private incomingImThreadWorkspace( + detail: ThreadDetailJson, + conversation: ClawImConversationV1 | undefined + ): string { + const record = detail as ThreadDetailJson & { workspace?: unknown } + return asString(record.workspace) || + asString(record.thread?.workspace) || + conversation?.workspaceRoot.trim() || + '' + } + + private runningTurnId(detail: ThreadDetailJson): string { + const turns = Array.isArray(detail.turns) ? detail.turns : [] + for (let index = turns.length - 1; index >= 0; index -= 1) { + const turn = turns[index] + if (turn.id.trim() && isRunningStatus(turn.status)) return turn.id.trim() + } + return '' + } + + private async stopIncomingImTurn(settings: AppSettingsV1, threadId: string): Promise { + const detail = await this.getIncomingImThreadDetail(settings, threadId) + const turnId = this.runningTurnId(detail) + if (!turnId) return null + const response = await this.deps.runtimeRequest( + settings, + `/v1/threads/${encodeURIComponent(threadId)}/turns/${encodeURIComponent(turnId)}/interrupt`, + { + method: 'POST', + body: JSON.stringify({ discard: false }) + } + ) + if (!response.ok) { + throw new Error(runtimeErrorMessage(response, 'Failed to stop turn.')) } + return turnId + } + + private async switchIncomingImThread( + settings: AppSettingsV1, + input: { + channel?: ClawImChannelV1 + conversation?: ClawImConversationV1 + remoteSession?: Pick + threadId: string + } + ): Promise { + if (!input.channel) return false const currentSettings = await this.deps.store.load() + const currentChannel = currentSettings.claw.channels.find((item) => item.id === input.channel?.id) + if (!currentChannel) return false + const session = input.remoteSession + const currentConversation = session + ? this.findChannelConversation(currentChannel, session) + : input.conversation + ? currentChannel.conversations.find((item) => item.id === input.conversation?.id) + : undefined const now = new Date().toISOString() + const modelResolution = currentImModelResolution(settings, currentChannel, input.conversation) + const nextConversation: ClawImConversationV1 | null = session && !currentConversation + ? { + id: randomUUID(), + chatId: session.chatId, + remoteThreadId: session.threadId, + latestMessageId: session.messageId, + senderId: session.senderId, + senderName: session.senderName, + localThreadId: input.threadId, + workspaceRoot: this.resolveConversationWorkspaceRoot(settings, currentChannel, session), + providerId: modelResolution.provider.id, + model: modelResolution.model, + createdAt: now, + updatedAt: now + } + : null await this.deps.store.patch({ claw: { - channels: currentSettings.claw.channels.map((item) => - item.id === channel.id - ? { - ...item, - providerId, - model, - updatedAt: now - } - : item - ) + channels: currentSettings.claw.channels.map((item) => { + if (item.id !== currentChannel.id) return item + return { + ...item, + threadId: input.threadId, + conversations: currentConversation + ? item.conversations.map((conversation) => + conversation.id === currentConversation.id + ? { + ...conversation, + latestMessageId: session?.messageId || conversation.latestMessageId, + senderId: session?.senderId || conversation.senderId, + senderName: session?.senderName || conversation.senderName, + localThreadId: input.threadId, + providerId: conversation.providerId, + model: conversation.model, + updatedAt: now + } + : conversation + ) + : nextConversation + ? [...item.conversations, nextConversation] + : item.conversations, + updatedAt: now + } + }) } }) + this.deps.notifyChannelActivity?.({ channelId: currentChannel.id, threadId: input.threadId }) + return true } private async handleIncomingImCommand( @@ -1110,26 +1925,111 @@ export class ClawRuntime { const command = parseClawCommand(input.text) if (!command) return null if (command.kind === 'help') return imCommandHelpText(settings) - if (command.kind === 'showProvider') return imProviderListText(settings, input.channel) - if (command.kind === 'provider') { - const provider = findImProvider(settings, command.providerId) - if (!provider) return imProviderCommandHint(settings, command.providerId) - const models = providerTextModels(settings, provider) - const currentModel = currentImModel(settings, input.channel) - const currentProviderModel = currentModel === DEFAULT_CLAW_MODEL - ? undefined - : findProviderModel(models, currentModel) - const nextModel = currentProviderModel ?? models[0] ?? DEFAULT_CLAW_MODEL - await this.setIncomingImProvider(input.channel, provider.id, nextModel) - return imProviderChangedText(settings, provider, nextModel) - } - if (command.kind === 'showModel') return imModelListText(settings, input.channel) + if (command.kind === 'stop') { + const currentThreadId = this.currentIncomingImThreadId(input.channel, input.conversation, input.remoteSession) + if (!currentThreadId) return imNoCurrentThreadText(settings) + const stoppedTurnId = await this.stopIncomingImTurn(settings, currentThreadId) + return stoppedTurnId + ? imStopSucceededText(settings, stoppedTurnId) + : imStopNoRunningTurnText(settings) + } + if (command.kind === 'showSkills') { + const result = await this.listIncomingImSkills(settings) + return imSkillListText(settings, result.enabled, result.skills) + } + if (command.kind === 'showMcp') { + return imMcpListText(settings, await this.listIncomingImMcpServers(settings)) + } + if (command.kind === 'showGoal') { + const currentThreadId = this.currentIncomingImThreadId(input.channel, input.conversation, input.remoteSession) + if (!currentThreadId) return imNoCurrentThreadText(settings) + return imGoalText(settings, await this.getIncomingImGoal(settings, currentThreadId)) + } + if (command.kind === 'showWorkspace') { + const currentThreadId = this.currentIncomingImThreadId(input.channel, input.conversation, input.remoteSession) + if (!currentThreadId) return imNoCurrentThreadText(settings) + const detail = await this.getIncomingImThreadDetail(settings, currentThreadId) + const workspace = this.incomingImThreadWorkspace(detail, input.conversation) + return workspace + ? imWorkspaceText(settings, currentThreadId, workspace) + : imWorkspaceMissingText(settings, currentThreadId) + } + if (command.kind === 'showUsage') { + const currentThreadId = this.currentIncomingImThreadId(input.channel, input.conversation, input.remoteSession) + if (!currentThreadId) return imNoCurrentThreadText(settings) + return imUsageText( + settings, + currentThreadId, + await this.getIncomingImThreadUsage(settings, currentThreadId), + currentImModelResolution(settings, input.channel, input.conversation) + ) + } + if (command.kind === 'invalidGoal') return imGoalMissingObjectiveText(settings) + if (command.kind === 'setGoal') { + const currentThreadId = this.currentIncomingImThreadId(input.channel, input.conversation, input.remoteSession) + if (!currentThreadId) return imNoCurrentThreadText(settings) + const existingGoal = await this.getIncomingImGoal(settings, currentThreadId) + if (existingGoal) return imGoalAlreadyExistsText(settings, existingGoal) + return imGoalChangedText( + settings, + await this.setIncomingImGoal(settings, currentThreadId, command.objective) + ) + } + if (command.kind === 'showThreads') { + const currentThreadId = this.currentIncomingImThreadId(input.channel, input.conversation, input.remoteSession) + const threads = await this.listIncomingImThreads(settings, settings.claw.im.recentThreadListLimit) + return imThreadListText(settings, threads, currentThreadId) + } + if (command.kind === 'showCurrentThread') { + const currentThreadId = this.currentIncomingImThreadId(input.channel, input.conversation, input.remoteSession) + if (!currentThreadId) return imCurrentThreadText(settings, undefined, currentThreadId) + const threads = await this.listIncomingImSwitchableThreads(settings, input.channel) + return imCurrentThreadText( + settings, + threads.find((thread) => thread.id === currentThreadId), + currentThreadId, + hasOtherImThreadBinding(settings, currentThreadId, input.channel?.id, input.conversation?.id) + ) + } + if (command.kind === 'switchThread') { + const switchTarget = command.target.trim() + const threads = await this.listIncomingImThreads( + settings, + /^\d+$/.test(switchTarget) ? settings.claw.im.recentThreadListLimit : 50 + ) + const target = resolveImThreadSwitchTarget(threads, command.target) + if (!target) return imThreadSwitchNotFoundText(settings, command.target) + const shared = hasOtherImThreadBinding( + settings, + target.id, + input.channel?.id, + input.conversation?.id + ) + const switched = await this.switchIncomingImThread(settings, { + channel: input.channel, + conversation: input.conversation, + remoteSession: input.remoteSession, + threadId: target.id + }) + if (!switched) { + return imKunErrorText(settings, isChineseLocale(settings) + ? '当前消息没有匹配到可保存切换状态的 IM 通道,无法切换会话。' + : 'This message did not match an IM channel that can persist thread switching.') + } + return imThreadSwitchedText(settings, target, shared) + } + if (command.kind === 'showModel') return imModelListText(settings, input.channel, input.conversation) if (command.kind === 'model') { - const provider = currentImProvider(settings, input.channel) - const model = findProviderModel(providerTextModels(settings, provider), command.model) - if (!model) return imModelCommandHint(settings, provider, command.model) - await this.setIncomingImModel(input.channel, model) - return imModelChangedText(settings, model) + const resolved = resolveImModelByIndex(settings, command.model) + if (!resolved) return imModelCommandHint(settings, command.model) + await this.setIncomingImProvider( + input.channel, + input.conversation, + input.remoteSession, + resolved.provider.id, + resolved.model + ) + return imModelChangedText(settings, resolved.provider.id, resolved.model) } if (command.kind === 'clear') { await this.resetIncomingImThread({ @@ -1142,6 +2042,29 @@ export class ClawRuntime { return null } + private async handleIncomingImCommandSafely( + settings: AppSettingsV1, + input: { + text: string + channel?: ClawImChannelV1 + conversation?: ClawImConversationV1 + remoteSession?: Pick + } + ): Promise { + try { + const reply = await this.handleIncomingImCommand(settings, input) + return reply === null ? null : imKunSystemText(reply) + } catch (error) { + const message = errorMessage(error) + this.deps.logError('claw-im-command', 'IM command failed', { + command: input.text.trim().slice(0, 80), + channelId: input.channel?.id, + message + }) + return imKunErrorText(settings, message || 'IM command failed.') + } + } + private async processIncomingImPrompt( settings: AppSettingsV1, input: { @@ -1162,16 +2085,21 @@ export class ClawRuntime { } ): Promise { const { channel, conversation, prompt, provider, remoteSession, sender } = input + const legacyChannelThreadId = remoteSession && !conversation && (channel?.conversations.length ?? 0) === 0 + ? channel?.threadId.trim() + : '' const initialThreadId = conversation?.localThreadId.trim() || - channel?.threadId.trim() || + legacyChannelThreadId || + (remoteSession ? '' : channel?.threadId.trim()) || '' + const modelResolution = currentImModelResolution(settings, channel, conversation) const result = await this.runPrompt(settings, { prompt, title: channel ? `[Claw IM:${channel.label}] ${sender}` : `[Claw IM:${provider}] ${sender}`, workspaceRoot: this.resolveIncomingWorkspaceRoot(settings, channel, conversation, remoteSession), - model: channel?.model ?? settings.claw.im.model, - providerId: channel?.providerId ?? settings.claw.im.providerId, + model: modelResolution.model, + providerId: modelResolution.provider.id, mode: settings.claw.im.mode, waitForResult: input.waitForResult !== false, responseTimeoutMs: settings.claw.im.responseTimeoutMs, @@ -1195,6 +2123,8 @@ export class ClawRuntime { senderName: remoteSession.senderName, localThreadId: threadId, workspaceRoot: this.resolveIncomingWorkspaceRoot(settings, channel, existingConversation, remoteSession), + providerId: existingConversation.providerId?.trim() || modelResolution.provider.id, + model: existingConversation.model?.trim() || modelResolution.model, updatedAt: now } : { @@ -1206,6 +2136,8 @@ export class ClawRuntime { senderName: remoteSession.senderName, localThreadId: threadId, workspaceRoot: this.resolveConversationWorkspaceRoot(settings, channel, remoteSession), + providerId: modelResolution.provider.id, + model: modelResolution.model, createdAt: now, updatedAt: now } @@ -1455,7 +2387,8 @@ export class ClawRuntime { settings: AppSettingsV1, threadId: string, workspaceRoot: string, - context: Record + context: Record, + turnId?: string ): Promise { const targetThreadId = threadId.trim() if (!targetThreadId) return [] @@ -1475,6 +2408,7 @@ export class ClawRuntime { } return latestGeneratedFiles(JSON.parse(detailRes.body) as ThreadDetailJson, { workspaceRoot, + ...(turnId ? { turnId } : {}), maxFiles: 3 }) } catch (error) { @@ -1654,7 +2588,7 @@ export class ClawRuntime { } } - const commandReply = await this.handleIncomingImCommand(settings, { + const commandReply = await this.handleIncomingImCommandSafely(settings, { text, channel, conversation, @@ -1665,11 +2599,12 @@ export class ClawRuntime { return } + const modelResolution = currentImModelResolution(settings, channel, conversation) const taskCreation = await this.deps.createScheduledTaskFromText?.(text, { workspaceRoot: this.resolveChannelWorkspaceRoot(settings, channel), clawChannelId: channel.id, - providerId: channel.providerId?.trim() || settings.claw.im.providerId?.trim() || null, - modelHint: channel.model, + providerId: modelResolution.provider.id, + modelHint: modelResolution.model, mode: settings.claw.im.mode }) ?? { kind: 'noop' as const } if (taskCreation.kind === 'created') { @@ -1677,7 +2612,11 @@ export class ClawRuntime { return } if (taskCreation.kind === 'error') { - await this.deps.telegramRuntime!.sendMessage(channel.id, remoteSession.chatId, `Failed to create the scheduled task: ${taskCreation.message}`) + await this.deps.telegramRuntime!.sendMessage( + channel.id, + remoteSession.chatId, + imKunErrorText(settings, `Failed to create the scheduled task: ${taskCreation.message}`) + ) return } @@ -1709,7 +2648,11 @@ export class ClawRuntime { chatId: remoteSession.chatId, message: result.message }) - await this.deps.telegramRuntime!.sendMessage(channel.id, remoteSession.chatId, `❌ 处理失败:${result.message}`) + await this.deps.telegramRuntime!.sendMessage( + channel.id, + remoteSession.chatId, + imKunErrorText(settings, `处理失败:${result.message}`) + ) return } if (result.completed === false) { @@ -1724,8 +2667,32 @@ export class ClawRuntime { await this.deps.telegramRuntime!.sendMessage(channel.id, remoteSession.chatId, IM_PROCESSING_ACK) return } - const reply = (result.text ?? '').trim() || IM_COMPLETED_NO_TEXT_REPLY + const generatedFiles = result.files ?? [] + const filesToSend = generatedFiles.length > 0 || shouldSendGeneratedFilesForPrompt(promptText) + ? await this.resolveImGeneratedFiles(generatedFiles, workspaceRoot, { + purpose: 'telegram-agent-file-resolve', + channelId: channel.id, + chatId: remoteSession.chatId, + inboundMessageId: remoteSession.messageId, + threadId: result.threadId, + turnId: result.turnId + }) + : [] + const reply = replyTextForGeneratedFiles( + (result.text ?? '').trim() || IM_COMPLETED_NO_TEXT_REPLY, + filesToSend + ) await this.deps.telegramRuntime!.sendMessage(channel.id, remoteSession.chatId, reply) + for (const file of filesToSend) { + const delivery = await this.deps.telegramRuntime!.sendFile(channel.id, remoteSession.chatId, file.path, file.fileName) + if (!delivery.ok) { + await this.deps.telegramRuntime!.sendMessage( + channel.id, + remoteSession.chatId, + imKunErrorText(settings, `Telegram 附件发送失败:${delivery.message}`) + ) + } + } } /** @@ -1797,7 +2764,7 @@ export class ClawRuntime { } } - const commandReply = await this.handleIncomingImCommand(settings, { + const commandReply = await this.handleIncomingImCommandSafely(settings, { text: message.content, channel, conversation, @@ -1820,11 +2787,12 @@ export class ClawRuntime { } const sender = feishuSenderLabel(message) + const modelResolution = currentImModelResolution(settings, channel, conversation) const taskCreation = await this.deps.createScheduledTaskFromText?.(message.content, { workspaceRoot: this.resolveChannelWorkspaceRoot(settings, channel), clawChannelId: channel.id, - providerId: channel.providerId?.trim() || settings.claw.im.providerId?.trim() || null, - modelHint: channel.model, + providerId: modelResolution.provider.id, + modelHint: modelResolution.model, mode: settings.claw.im.mode }) ?? { kind: 'noop' as const } if (taskCreation.kind === 'created') { @@ -1846,7 +2814,7 @@ export class ClawRuntime { await this.sendFeishuMessage( bridge, message.chatId, - { markdown: `Failed to create the scheduled task: ${taskCreation.message}` }, + { markdown: imKunErrorText(settings, `Failed to create the scheduled task: ${taskCreation.message}`) }, { replyTo: message.messageId, replyInThread: Boolean(message.threadId) }, { purpose: 'schedule-error', @@ -1862,7 +2830,7 @@ export class ClawRuntime { await this.sendFeishuMessage( bridge, message.chatId, - { markdown: 'Only text messages are supported right now.' }, + { markdown: imKunErrorText(settings, 'Only text messages are supported right now.') }, { replyTo: message.messageId, replyInThread: Boolean(message.threadId) }, { purpose: 'unsupported-message', @@ -2033,6 +3001,20 @@ export class ClawRuntime { }) if (streamResult.ok) { const streamedText = streamResult.finalText.trim() || 'Completed.' + const streamFiles = await this.recentGeneratedFilesForThread( + settings, + started.threadId, + workspaceRoot, + { + purpose: 'feishu-stream-file-lookup', + channelId, + chatId: message.chatId, + inboundMessageId: message.messageId, + threadId: started.threadId, + turnId: started.turnId + }, + started.turnId + ) // Either the streaming card (FeishuStreamer) or its one-shot // fallback already delivered the text to the chat. Mark // `streamedToFeishu` so the post-branch sendFeishuMessage @@ -2043,7 +3025,9 @@ export class ClawRuntime { threadId: started.threadId, turnId: started.turnId, text: streamedText, - message: streamResult.fellBack ? 'streamed (fell back to one-shot send)' : 'streamed' + message: streamResult.fellBack ? 'streamed (fell back to one-shot send)' : 'streamed', + files: streamFiles, + completed: true } } else { result = { @@ -2073,7 +3057,7 @@ export class ClawRuntime { await this.sendFeishuMessage( bridge, message.chatId, - { markdown: 'Sorry, I could not process your message right now.' }, + { markdown: imKunErrorText(settings, 'Sorry, I could not process your message right now.') }, { replyTo: message.messageId, replyInThread: Boolean(message.threadId) }, { purpose: 'processing-error', @@ -2113,7 +3097,7 @@ export class ClawRuntime { : [] const replyText = result.ok ? replyTextForGeneratedFiles(result.text?.trim() || result.message?.trim() || 'Completed.', filesToSend) - : (result.message.trim() || 'Sorry, something went wrong while handling your message.') + : imKunErrorText(settings, result.message.trim() || 'Sorry, something went wrong while handling your message.') const resultThreadId = result.ok ? result.threadId : undefined const resultTurnId = result.ok ? result.turnId : undefined // The streaming path already delivered the text (either as a live @@ -2360,16 +3344,16 @@ export class ClawRuntime { ok: false, code: 'gui_plan_create_retired', message: - 'The /claw/internal/gui-plan/create endpoint is no longer active. Use the Kun create_plan tool.' + 'Kun: The /claw/internal/gui-plan/create endpoint is no longer active. Use the Kun create_plan tool.' }) return } if (req.method !== 'POST' || url.pathname !== im.path) { - writeJson(res, 404, { ok: false, message: 'Not found.' }) + writeJson(res, 404, { ok: false, message: 'Kun: Not found.' }) return } if (!settings.claw.enabled || !im.enabled) { - writeJson(res, 503, { ok: false, message: 'Claw IM webhook is disabled.' }) + writeJson(res, 503, { ok: false, message: 'Kun: Claw IM webhook is disabled.' }) return } if (im.secret) { @@ -2379,7 +3363,7 @@ export class ClawRuntime { const rawHeaderSecret = req.headers['x-kun-secret'] ?? req.headers['x-deepseek-gui-secret'] const headerSecret = Array.isArray(rawHeaderSecret) ? rawHeaderSecret[0] : rawHeaderSecret if (auth !== `Bearer ${im.secret}` && headerSecret !== im.secret) { - writeJson(res, 401, { ok: false, message: 'Unauthorized.' }) + writeJson(res, 401, { ok: false, message: 'Kun: Unauthorized.' }) return } } @@ -2387,12 +3371,12 @@ export class ClawRuntime { const body = await readRequestBody(req) const payload = parseJsonObject(body) if (!payload) { - writeJson(res, 400, { ok: false, message: 'Expected a JSON object.' }) + writeJson(res, 400, { ok: false, message: 'Kun: Expected a JSON object.' }) return } const prompt = extractIncomingPrompt(payload) if (!prompt) { - writeJson(res, 400, { ok: false, message: 'No message text found.' }) + writeJson(res, 400, { ok: false, message: 'Kun: No message text found.' }) return } const sender = extractSenderLabel(payload) @@ -2436,7 +3420,7 @@ export class ClawRuntime { this.welcomeInFlight.delete(channel.id) } } - const commandReply = await this.handleIncomingImCommand(settings, { + const commandReply = await this.handleIncomingImCommandSafely(settings, { text: prompt, channel, conversation, @@ -2446,11 +3430,12 @@ export class ClawRuntime { writeJson(res, 200, { ok: true, reply: `${welcomePrefix}${commandReply}` }) return } + const modelResolution = currentImModelResolution(settings, channel, conversation) const taskCreation = await this.deps.createScheduledTaskFromText?.(prompt, { workspaceRoot: this.resolveChannelWorkspaceRoot(settings, channel), clawChannelId: channel?.id ?? null, - providerId: channel?.providerId?.trim() || im.providerId?.trim() || null, - modelHint: channel?.model ?? im.model, + providerId: modelResolution.provider.id, + modelHint: modelResolution.model, mode: im.mode }) ?? { kind: 'noop' as const } if (taskCreation.kind === 'created') { @@ -2458,7 +3443,8 @@ export class ClawRuntime { return } if (taskCreation.kind === 'error') { - writeJson(res, 500, { ok: false, message: taskCreation.message }) + const reply = imKunErrorText(settings, taskCreation.message) + writeJson(res, 500, { ok: false, message: reply, reply }) return } const result = await this.processIncomingImPrompt(settings, { @@ -2470,7 +3456,11 @@ export class ClawRuntime { remoteSession: remoteSession ?? undefined }) if (!result.ok) { - writeJson(res, 500, result) + writeJson(res, 500, { + ...result, + message: imKunErrorText(settings, result.message), + reply: imKunErrorText(settings, result.message) + }) return } if (result.completed === false) { @@ -2515,7 +3505,7 @@ export class ClawRuntime { } catch (error) { const message = error instanceof Error ? error.message : String(error) this.deps.logError('claw-webhook', 'Claw IM webhook request failed', { message }) - writeJson(res, 500, { ok: false, message: 'Internal server error.' }) + writeJson(res, 500, { ok: false, message: 'Kun: Internal server error.' }) } } } diff --git a/src/main/feishu-streamer.test.ts b/src/main/feishu-streamer.test.ts index 5d46e0290..7fdcdfe82 100644 --- a/src/main/feishu-streamer.test.ts +++ b/src/main/feishu-streamer.test.ts @@ -47,6 +47,27 @@ function makeSubscriber( } describe('FeishuStreamer', () => { + it('buffers synchronous events until the stream controller is ready', async () => { + const { bridge, controller, messageId } = makeBridge() + const streamer = new FeishuStreamer({ + bridge, chatId: 'oc_chat_1', turnId: 'turn_1', threadId: 'thr_1', + replyOptions: {}, logger: vi.fn() + }) + const close = vi.fn() + const subscribe: SseSubscriber = () => { + streamer.onSseEvent({ kind: 'assistant_text_delta', turnId: 'turn_1', item: { text: 'early' } }) + streamer.onSseEvent({ kind: 'turn_completed', turnId: 'turn_1' }) + return { close } + } + + const result = await streamer.start({ subscribe }) + + expect(close).toHaveBeenCalledTimes(1) + expect(controller.append).toHaveBeenCalledWith('early') + expect(controller.setContent).toHaveBeenCalledWith('early') + expect(result).toEqual({ ok: true, messageId, finalText: 'early', fellBack: false }) + }) + it('streams assistant_text_delta in order, calls setContent once on turn_completed, resolves with messageId', async () => { const { bridge, controller, messageId } = makeBridge() const streamer = new FeishuStreamer({ diff --git a/src/main/feishu-streamer.ts b/src/main/feishu-streamer.ts index e4e4f800c..cd4c77685 100644 --- a/src/main/feishu-streamer.ts +++ b/src/main/feishu-streamer.ts @@ -30,6 +30,7 @@ export class FeishuStreamer { private subscription: { close: () => void } | null = null private startAbortController: AbortController | null = null private failed = false + private ended = false constructor(opts: FeishuStreamerOptions) { this.opts = opts @@ -40,6 +41,7 @@ export class FeishuStreamer { const controller = new AbortController() this.startAbortController = controller this.failed = false + this.ended = false let resolved = false const onComplete = (result: FeishuStreamerResult): void => { if (resolved) return @@ -99,7 +101,9 @@ export class FeishuStreamer { } } - this.subscription = input.subscribe(controller.signal) + const subscription = input.subscribe(controller.signal) + if (this.ended || controller.signal.aborted) subscription.close() + else this.subscription = subscription const onAbort = (): void => { this.state = 'closed' this.subscription?.close() @@ -134,7 +138,7 @@ export class FeishuStreamer { } onSseEvent(event: Record): void { - if (this.state !== 'streaming') return + if (this.state === 'closed' || this.ended) return const kind = event.kind // 关键:读 event.item.text,不是 event.item.delta if (kind === 'assistant_text_delta' && event.turnId === this.opts.turnId) { @@ -152,6 +156,7 @@ export class FeishuStreamer { event.turnId === this.opts.turnId ) { if (kind === 'turn_failed') this.failed = true + this.ended = true this.subscription?.close() this.subscription = null this.push(null) diff --git a/src/main/gui-updater.test.ts b/src/main/gui-updater.test.ts index 5c410837e..d54dcf2df 100644 --- a/src/main/gui-updater.test.ts +++ b/src/main/gui-updater.test.ts @@ -18,6 +18,7 @@ let updater: MockUpdater let nativeUpdater: EventEmitter let originalEnv: NodeJS.ProcessEnv let appVersion: string +let appIsPackaged: boolean let mockedFiles: Map let showMessageBox: ReturnType let openExternal: ReturnType @@ -43,6 +44,7 @@ beforeEach(() => { updater = createUpdater() nativeUpdater = new EventEmitter() appVersion = '0.1.0' + appIsPackaged = true mockedFiles = new Map() showMessageBox = vi.fn().mockResolvedValue({ response: 1 }) openExternal = vi.fn().mockResolvedValue(undefined) @@ -59,7 +61,9 @@ beforeEach(() => { })) vi.doMock('electron', () => ({ app: { - isPackaged: true, + get isPackaged() { + return appIsPackaged + }, getAppPath: () => '/tmp/deepseek-gui-updater-test-app', getPath: () => '/tmp/deepseek-gui-updater-test-user-data', getVersion: () => appVersion, @@ -265,6 +269,35 @@ describe('showPostUpdateReleaseNotes', () => { }) }) + it('does not show or overwrite release-note state in development', async () => { + appIsPackaged = false + appVersion = '0.1.0' + mockedFiles.set(versionStatePath, JSON.stringify({ lastSeenVersion: '0.2.0' })) + const module = await import('./gui-updater') + module.initializeGuiUpdater(() => null, () => 'stable') + + await module.showPostUpdateReleaseNotes() + + expect(showMessageBox).not.toHaveBeenCalled() + expect(JSON.parse(mockedFiles.get(versionStatePath) ?? '{}')).toEqual({ + lastSeenVersion: '0.2.0' + }) + }) + + it('does not show release notes when launching an older version', async () => { + appVersion = '0.1.0' + mockedFiles.set(versionStatePath, JSON.stringify({ lastSeenVersion: '0.2.0' })) + const module = await import('./gui-updater') + module.initializeGuiUpdater(() => null, () => 'stable') + + await module.showPostUpdateReleaseNotes() + + expect(showMessageBox).not.toHaveBeenCalled() + expect(JSON.parse(mockedFiles.get(versionStatePath) ?? '{}')).toEqual({ + lastSeenVersion: '0.2.0' + }) + }) + it('shows downloaded release notes once after the version changes', async () => { appVersion = '0.2.0' mockedFiles.set( diff --git a/src/main/gui-updater.ts b/src/main/gui-updater.ts index e5b10cc9c..34c31cd74 100644 --- a/src/main/gui-updater.ts +++ b/src/main/gui-updater.ts @@ -630,6 +630,8 @@ export function initializeGuiUpdater( } export async function showPostUpdateReleaseNotes(): Promise { + if (!app.isPackaged) return + const currentVersion = app.getVersion().trim() const state = await readGuiVersionState() if (!state.lastSeenVersion) { @@ -637,6 +639,7 @@ export async function showPostUpdateReleaseNotes(): Promise { return } if (state.lastSeenVersion === currentVersion) return + if (!isVersionGreater(currentVersion, state.lastSeenVersion)) return const pendingUpdate = state.pendingUpdate?.version === currentVersion ? state.pendingUpdate : undefined diff --git a/src/main/ipc/app-ipc-schemas.test.ts b/src/main/ipc/app-ipc-schemas.test.ts index d37e0b83d..9618a5e23 100644 --- a/src/main/ipc/app-ipc-schemas.test.ts +++ b/src/main/ipc/app-ipc-schemas.test.ts @@ -219,6 +219,8 @@ describe('app-ipc-schemas', () => { } }, write: { + autoSaveEnabled: false, + autoSaveDelayMs: 180000, inlineCompletion: { model: 'deepseek-v4-pro', maxTokens: 128 @@ -246,6 +248,8 @@ describe('app-ipc-schemas', () => { expect(payload.agents?.kun?.tokenEconomy?.historyHygiene?.maxToolResultTokens).toBe(4000) expect(payload.agents?.kun?.toolOutputLimits?.maxLines).toBe(30000) expect(payload.agents?.kun?.toolOutputLimits?.maxBytes).toBe(1048576) + expect(payload.write?.autoSaveEnabled).toBe(false) + expect(payload.write?.autoSaveDelayMs).toBe(180000) expect(payload.write?.inlineCompletion?.model).toBe('deepseek-v4-pro') expect(payload.write?.selectionAssist?.infographicPrompt).toBe('手绘风格信息图。') expect(payload.write?.selectionAssist?.quickActions).toHaveLength(2) @@ -341,6 +345,39 @@ describe('app-ipc-schemas', () => { expect(payload.agents?.kun?.videoGeneration?.defaultResolution).toBe('1080P') }) + it('accepts provider and resolved runtime retry settings', () => { + const payload = settingsPatchSchema.parse({ + provider: { + providers: [{ + id: 'deepseek', + name: 'DeepSeek', + apiKey: 'sk-test', + baseUrl: 'https://api.deepseek.com', + endpointFormat: 'chat_completions', + retry: { + maxAttempts: 3, + initialDelayMs: 3000, + httpStatusCodes: [429, 503] + }, + models: ['deepseek-chat'], + modelProfiles: {} + }] + }, + agents: { + kun: { + retry: { + maxAttempts: 3, + initialDelayMs: 3000, + httpStatusCodes: [429, 503] + } + } + } + }) + + expect(payload.provider?.providers?.[0]?.retry?.maxAttempts).toBe(3) + expect(payload.agents?.kun?.retry?.httpStatusCodes).toEqual([429, 503]) + }) + it('accepts long provider model ids imported from upstream catalogs', () => { const longModelId = `openrouter/${'provider-routed-model-id-'.repeat(6)}preview` diff --git a/src/main/ipc/app-ipc-schemas.ts b/src/main/ipc/app-ipc-schemas.ts index 416b5f72d..1d0a99eb4 100644 --- a/src/main/ipc/app-ipc-schemas.ts +++ b/src/main/ipc/app-ipc-schemas.ts @@ -43,6 +43,8 @@ import { MODEL_PROVIDER_MESSAGE_PARTS, MODEL_REASONING_EFFORTS, MODEL_REASONING_REQUEST_PROTOCOLS, + MAX_WRITE_AUTOSAVE_DELAY_MS, + MIN_WRITE_AUTOSAVE_DELAY_MS, MIN_KUN_LOCAL_PORT, SCHEDULE_MODEL_IDS, SCHEDULE_REASONING_EFFORT_IDS, @@ -131,6 +133,12 @@ export const providerProbePayloadSchema = z }) .strict() +export const promptOptimizationPayloadSchema = z + .object({ + text: trimmedString(100_000) + }) + .strict() + interface EndpointTemplate { /** Compiled path matcher. */ match(path: string): boolean @@ -302,6 +310,11 @@ const modelProviderPatchSchema = z.object({ apiKey: z.string().max(MAX_BODY_BYTES).optional(), baseUrl: z.string().trim().max(MAX_URL_LENGTH).optional(), endpointFormat: modelEndpointFormatSchema.optional(), + retry: z.object({ + maxAttempts: z.number().int().min(0).max(10).optional(), + initialDelayMs: z.number().int().min(0).max(600_000).optional(), + httpStatusCodes: z.array(z.number().int().min(400).max(599)).max(64).optional() + }).strict().optional(), kind: z.enum(['http', 'agent-sdk']).optional(), // Some third-party aggregators (litellm, oneapi, …) advertise 500+ chat // models in a single /v1/models response. The previous 200/50 caps caused @@ -386,6 +399,11 @@ const kunRuntimePatchSchema = z.object({ baseUrl: z.string().trim().max(MAX_URL_LENGTH).optional(), providerId: z.string().trim().max(64).optional(), endpointFormat: modelEndpointFormatSchema.optional(), + retry: z.object({ + maxAttempts: z.number().int().min(0).max(10).optional(), + initialDelayMs: z.number().int().min(0).max(600_000).optional(), + httpStatusCodes: z.array(z.number().int().min(400).max(599)).max(64).optional() + }).strict().optional(), runtimeToken: z.string().max(MAX_BODY_BYTES).optional(), dataDir: defaultPathSchema, model: modelIdSchema.optional(), @@ -484,6 +502,13 @@ const kunRuntimePatchSchema = z.object({ format: z.string().trim().max(16).optional(), timeoutMs: z.number().int().positive().max(900_000).optional() }).strict().optional(), + promptOptimization: z.object({ + enabled: z.boolean().optional(), + providerId: z.string().trim().max(64).optional(), + model: optionalModelIdSchema, + prompt: z.string().trim().max(MAX_BODY_BYTES).optional(), + timeoutMs: z.number().int().positive().max(600_000).optional() + }).strict().optional(), musicGeneration: z.object({ enabled: z.boolean().optional(), providerId: z.string().trim().max(64).optional(), @@ -632,6 +657,8 @@ const writeSettingsPatchSchema = z.object({ defaultWorkspaceRoot: defaultPathSchema, activeWorkspaceRoot: defaultPathSchema, workspaces: z.array(trimmedString(MAX_PATH_LENGTH)).max(256).optional(), + autoSaveEnabled: z.boolean().optional(), + autoSaveDelayMs: z.number().int().min(MIN_WRITE_AUTOSAVE_DELAY_MS).max(MAX_WRITE_AUTOSAVE_DELAY_MS).optional(), inlineCompletion: writeInlineCompletionPatchSchema.optional(), selectionAssist: writeSelectionAssistPatchSchema.optional(), typography: writeTypographyPatchSchema.optional(), @@ -685,7 +712,8 @@ const clawImPatchSchema = z.object({ providerId: z.string().trim().max(64).optional(), model: modelIdSchema.optional(), mode: clawRunModeSchema.optional(), - responseTimeoutMs: z.number().int().min(5_000).max(600_000).optional() + responseTimeoutMs: z.number().int().min(5_000).max(600_000).optional(), + recentThreadListLimit: z.number().int().min(1).max(50).optional() }).strict() const clawImAgentProfilePatchSchema = z.object({ @@ -738,6 +766,8 @@ const clawImConversationPatchSchema = z.object({ senderName: z.string().max(512).optional(), localThreadId: z.string().max(MAX_ID_LENGTH).optional(), workspaceRoot: defaultPathSchema, + providerId: z.string().trim().max(64).optional(), + model: z.string().trim().max(128).optional(), createdAt: z.string().max(128).optional(), updatedAt: z.string().max(128).optional() }).strict() diff --git a/src/main/ipc/register-app-ipc-handlers.test.ts b/src/main/ipc/register-app-ipc-handlers.test.ts index 5c03058c2..89e4ec8fe 100644 --- a/src/main/ipc/register-app-ipc-handlers.test.ts +++ b/src/main/ipc/register-app-ipc-handlers.test.ts @@ -16,6 +16,7 @@ import { type AppSettingsPatch, type AppSettingsV1 } from '../../shared/app-settings' +import { registerAppIpcHandlers } from './register-app-ipc-handlers' const handlers = new Map Promise>() @@ -97,7 +98,6 @@ describe('registerAppIpcHandlers', () => { }) it('rejects invalid settings patches at the handler boundary', async () => { - const { registerAppIpcHandlers } = await import('./register-app-ipc-handlers') const applySettingsPatch = vi.fn(async () => settings()) registerAppIpcHandlers(registerOptions({ applySettingsPatch })) @@ -111,7 +111,6 @@ describe('registerAppIpcHandlers', () => { }) it('passes valid settings patches through to applySettingsPatch', async () => { - const { registerAppIpcHandlers } = await import('./register-app-ipc-handlers') const applySettingsPatch = vi.fn(async () => settings()) registerAppIpcHandlers(registerOptions({ applySettingsPatch })) @@ -130,7 +129,6 @@ describe('registerAppIpcHandlers', () => { }) it('accepts checkpoint cleanup settings patches', async () => { - const { registerAppIpcHandlers } = await import('./register-app-ipc-handlers') const applySettingsPatch = vi.fn(async () => settings()) registerAppIpcHandlers(registerOptions({ applySettingsPatch })) @@ -146,7 +144,6 @@ describe('registerAppIpcHandlers', () => { }) it('rejects unsupported checkpoint cleanup intervals', async () => { - const { registerAppIpcHandlers } = await import('./register-app-ipc-handlers') const applySettingsPatch = vi.fn(async () => settings()) registerAppIpcHandlers(registerOptions({ applySettingsPatch })) @@ -159,7 +156,6 @@ describe('registerAppIpcHandlers', () => { }) it('accepts telegram phone connection settings patches', async () => { - const { registerAppIpcHandlers } = await import('./register-app-ipc-handlers') const applySettingsPatch = vi.fn(async () => settings()) registerAppIpcHandlers(registerOptions({ applySettingsPatch })) @@ -204,7 +200,6 @@ describe('registerAppIpcHandlers', () => { }) it('restarts the managed runtime through the restart IPC handler', async () => { - const { registerAppIpcHandlers } = await import('./register-app-ipc-handlers') const restartRuntime = vi.fn(async () => undefined) registerAppIpcHandlers(registerOptions({ restartRuntime })) @@ -215,7 +210,6 @@ describe('registerAppIpcHandlers', () => { it('saves generated files to a user-selected path', async () => { const { dialog } = await import('electron') - const { registerAppIpcHandlers } = await import('./register-app-ipc-handlers') const temp = mkdtempSync(join(tmpdir(), 'kun-save-as-')) const source = join(temp, 'source.png') const target = join(temp, 'downloaded.png') @@ -241,7 +235,6 @@ describe('registerAppIpcHandlers', () => { }) it('accepts the full settings snapshot emitted by SettingsView auto-apply', async () => { - const { registerAppIpcHandlers } = await import('./register-app-ipc-handlers') const applySettingsPatch = vi.fn(async () => settings()) registerAppIpcHandlers(registerOptions({ applySettingsPatch })) @@ -253,7 +246,6 @@ describe('registerAppIpcHandlers', () => { }) it('passes schedule settings patches through to applySettingsPatch', async () => { - const { registerAppIpcHandlers } = await import('./register-app-ipc-handlers') const applySettingsPatch = vi.fn(async (partial: AppSettingsPatch) => ({ ...settings(), schedule: mergeScheduleSettings(settings().schedule, partial.schedule) @@ -286,7 +278,6 @@ describe('registerAppIpcHandlers', () => { }) it('writes MCP config JSON and notifies the runtime apply hook', async () => { - const { registerAppIpcHandlers } = await import('./register-app-ipc-handlers') const tempRoot = mkdtempSync(join(tmpdir(), 'deepseek-gui-ipc-')) const configPath = join(tempRoot, 'mcp.json') const onKunMcpConfigWritten = vi.fn(async () => undefined) @@ -317,7 +308,6 @@ describe('registerAppIpcHandlers', () => { }) it('rejects invalid MCP config JSON before writing or applying it', async () => { - const { registerAppIpcHandlers } = await import('./register-app-ipc-handlers') const tempRoot = mkdtempSync(join(tmpdir(), 'deepseek-gui-ipc-')) const configPath = join(tempRoot, 'mcp.json') const onKunMcpConfigWritten = vi.fn(async () => undefined) @@ -342,7 +332,6 @@ describe('registerAppIpcHandlers', () => { }) it('uses the GUI-managed WeChat bridge for WeChat install handlers', async () => { - const { registerAppIpcHandlers } = await import('./register-app-ipc-handlers') const configuredSettings = settings() configuredSettings.claw.im.weixinBridgeUrl = 'http://127.0.0.1:18787/rpc' const store = { load: vi.fn(async () => configuredSettings) } @@ -370,7 +359,6 @@ describe('registerAppIpcHandlers', () => { }) it('routes schedule task IPC calls to the Schedule runtime', async () => { - const { registerAppIpcHandlers } = await import('./register-app-ipc-handlers') const scheduleRuntime = { status: vi.fn(async () => ({ internalServerRunning: true, @@ -423,7 +411,6 @@ describe('registerAppIpcHandlers', () => { }) it('routes desktop command IPC calls to the focused window and web contents', async () => { - const { registerAppIpcHandlers } = await import('./register-app-ipc-handlers') const webContents = { undo: vi.fn(), redo: vi.fn(), @@ -463,7 +450,6 @@ describe('registerAppIpcHandlers', () => { }) it('creates a unique conversation workspace, suffixing on timestamp collision', async () => { - const { registerAppIpcHandlers } = await import('./register-app-ipc-handlers') const root = mkdtempSync(join(tmpdir(), 'kun-conv-')) try { registerAppIpcHandlers(registerOptions({ diff --git a/src/main/ipc/register-app-ipc-handlers.ts b/src/main/ipc/register-app-ipc-handlers.ts index 0c3d670c8..ec4ab71b4 100644 --- a/src/main/ipc/register-app-ipc-handlers.ts +++ b/src/main/ipc/register-app-ipc-handlers.ts @@ -52,6 +52,7 @@ import { notificationPayloadSchema, openEditorPathPayloadSchema, providerProbePayloadSchema, + promptOptimizationPayloadSchema, rootPathSchema, worktreeCommitSchema, worktreeContinueMergeSchema, @@ -186,6 +187,7 @@ import { retrieveWriteContext } from '../services/write-retrieval-service' import { requestWriteInfographic } from '../services/write-infographic-service' import { authorizePrototypePath } from '../services/prototype-embed-registry' import { requestSpeechTranscription } from '../services/speech-to-text-service' +import { optimizePrompt } from '../services/prompt-optimization-service' import { cancelLocalWhisperModel, deleteLocalWhisperModel, @@ -581,6 +583,11 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions): return probeModelProvider(request, await store.load()) }) + ipcMain.handle('prompt:optimize', async (_, payload: unknown) => { + const request = parseIpcPayload('prompt:optimize', promptOptimizationPayloadSchema, payload) + return optimizePrompt(await store.load(), request.text) + }) + ipcMain.handle('claw:status', async (): Promise => getClawRuntime()?.status() ?? { imServerRunning: false, diff --git a/src/main/kun-process.ts b/src/main/kun-process.ts index f4e841f8d..6f4c02081 100644 --- a/src/main/kun-process.ts +++ b/src/main/kun-process.ts @@ -367,10 +367,6 @@ async function startKunChildOnce( host: '127.0.0.1', port: runtime.port, dataDir, - baseUrl: runtime.baseUrl, - modelProxyUrl: resolveModelProviderProxyUrl(settings), - endpointFormat: runtime.endpointFormat, - model: runtime.model, approvalPolicy: runtime.approvalPolicy, sandboxMode: runtime.sandboxMode, tokenEconomyMode: runtime.tokenEconomyMode, @@ -474,6 +470,7 @@ export async function syncGuiManagedKunConfig( KunRuntimeSettingsV1, | 'apiKey' | 'mcpSearch' + | 'retry' | 'tokenEconomy' | 'toolOutputLimits' | 'storage' @@ -555,6 +552,7 @@ export async function syncGuiManagedKunConfig( serve: { ...serve, storage, + retry: runtime.retry, tokenEconomy: tokenEconomyConfigForRuntime(runtime.tokenEconomy, existingTokenEconomy), toolOutputLimits: toolOutputLimitsConfigForRuntime(runtime.toolOutputLimits), headers: defaultClientHeaders, @@ -921,6 +919,7 @@ function providersConfigForRuntime(settings: AppSettingsV1): Record { host: '127.0.0.1', port: 18899, dataDir: '/tmp/kun', - baseUrl: 'https://api.deepseek.com/beta', - endpointFormat: 'responses', - model: 'deepseek-chat', approvalPolicy: 'on-request', sandboxMode: 'workspace-write', tokenEconomyMode: false, @@ -111,8 +108,10 @@ describe('buildKunServeArgs', () => { expect(args).not.toContain('--api-key') expect(args).not.toContain('--runtime-token') - expect(args).toContain('--endpoint-format') - expect(args).toContain('responses') + expect(args).not.toContain('--base-url') + expect(args).not.toContain('--model-proxy-url') + expect(args).not.toContain('--endpoint-format') + expect(args).not.toContain('--model') expect(args).toContain('--token-economy-mode') expect(args).toContain('false') }) diff --git a/src/main/resolve-kun-binary.ts b/src/main/resolve-kun-binary.ts index b08b60fe2..7ea0930a6 100644 --- a/src/main/resolve-kun-binary.ts +++ b/src/main/resolve-kun-binary.ts @@ -113,10 +113,6 @@ export function buildKunServeArgs(input: { host: string port: number dataDir: string - baseUrl?: string - modelProxyUrl?: string - endpointFormat?: string - model: string approvalPolicy: string sandboxMode: string tokenEconomyMode: boolean @@ -130,11 +126,6 @@ export function buildKunServeArgs(input: { String(input.port), '--data-dir', input.dataDir, - ...(input.baseUrl ? ['--base-url', input.baseUrl] : []), - ...(input.modelProxyUrl ? ['--model-proxy-url', input.modelProxyUrl] : []), - ...(input.endpointFormat ? ['--endpoint-format', input.endpointFormat] : []), - '--model', - input.model, '--approval-policy', input.approvalPolicy, '--sandbox-mode', diff --git a/src/main/services/git-checkpoint-service.test.ts b/src/main/services/git-checkpoint-service.test.ts index b6894ea03..408cbf1e9 100644 --- a/src/main/services/git-checkpoint-service.test.ts +++ b/src/main/services/git-checkpoint-service.test.ts @@ -241,7 +241,7 @@ describe('git checkpoint service', () => { // traversal that a lexical-only check misses. const outsideDir = join(sandbox, 'outside') await mkdir(outsideDir, { recursive: true }) - await symlink(outsideDir, join(repoRoot, 'link'), 'dir') + await symlink(outsideDir, join(repoRoot, 'link'), process.platform === 'win32' ? 'junction' : 'dir') await expect( testResolvePathWithinRepository(repoRoot, 'link/payload.txt') diff --git a/src/main/services/prompt-optimization-service.test.ts b/src/main/services/prompt-optimization-service.test.ts new file mode 100644 index 000000000..7238f0f1d --- /dev/null +++ b/src/main/services/prompt-optimization-service.test.ts @@ -0,0 +1,191 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + DEFAULT_PROMPT_OPTIMIZATION_PROMPT, + defaultClawSettings, + defaultDesignSettings, + defaultKeyboardShortcuts, + defaultKunRuntimeSettings, + defaultModelProviderSettings, + defaultScheduleSettings, + defaultTerminalSettings, + defaultWorkflowSettings, + defaultWriteSettings, + type AppSettingsV1 +} from '../../shared/app-settings' +import { optimizePrompt } from './prompt-optimization-service' + +function createSettings(patch: Partial = {}): AppSettingsV1 { + return { + version: 1, + locale: 'en', + theme: 'system', + uiFontScale: 0.82, + chatContentMaxWidthPx: 896, + provider: defaultModelProviderSettings(), + agents: { + kun: { + ...defaultKunRuntimeSettings(), + apiKey: 'sk-runtime', + promptOptimization: { + ...defaultKunRuntimeSettings().promptOptimization, + enabled: true + }, + ...patch + } + }, + workspaceRoot: '/tmp/workspace', + conversationWorkspaceRoot: '~/Documents/Kun', + log: { + enabled: true, + retentionDays: 2 + }, + checkpointCleanup: { enabled: false, intervalDays: 3 }, + notifications: { + turnComplete: true + }, + appBehavior: { openAtLogin: false, startMinimized: false, closeToTray: false }, + keyboardShortcuts: defaultKeyboardShortcuts(), + write: defaultWriteSettings(), + schedule: defaultScheduleSettings(), + workflow: defaultWorkflowSettings(), + terminal: defaultTerminalSettings(), + guiUpdate: { + channel: 'stable' + }, + design: defaultDesignSettings(), + codePromptPrefix: '', + disabledSkillIds: [], + claw: defaultClawSettings() + } +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('optimizePrompt', () => { + it('uses the default prompt and replaces rough text with the model response', async () => { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ + choices: [{ message: { content: 'Implement prompt optimization with a composer button.' } }] + }), { status: 200 }) + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await optimizePrompt(createSettings(), '嗯 加个按钮 优化一下 prompt') + + expect(result).toEqual({ + ok: true, + text: 'Implement prompt optimization with a composer button.', + model: 'deepseek-v4-pro', + providerId: 'deepseek' + }) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.deepseek.com/v1/chat/completions', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer sk-runtime' + }) + }) + ) + const firstCall = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + const body = JSON.parse(String(firstCall[1].body)) as { + model: string + messages: Array<{ role: string; content: string }> + } + expect(body.model).toBe('deepseek-v4-pro') + expect(body.messages[0]).toEqual({ + role: 'system', + content: DEFAULT_PROMPT_OPTIMIZATION_PROMPT + }) + expect(body.messages[1]).toEqual({ + role: 'user', + content: '嗯 加个按钮 优化一下 prompt' + }) + }) + + it('honors custom prompt optimization model settings', async () => { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ + choices: [{ message: { content: 'Use the configured optimizer model.' } }] + }), { status: 200 }) + ) + vi.stubGlobal('fetch', fetchMock) + const settings = createSettings({ + promptOptimization: { + enabled: true, + providerId: '', + model: 'deepseek-v4-flash', + prompt: 'Rewrite only.', + timeoutMs: 12345 + } + }) + + const result = await optimizePrompt(settings, 'rewrite this') + + expect(result).toEqual({ + ok: true, + text: 'Use the configured optimizer model.', + model: 'deepseek-v4-flash', + providerId: 'deepseek' + }) + const firstCall = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + const body = JSON.parse(String(firstCall[1].body)) as { + model: string + messages: Array<{ role: string; content: string }> + } + expect(body.model).toBe('deepseek-v4-flash') + expect(body.messages[0].content).toBe('Rewrite only.') + }) + + it('uses the selected optimizer provider model instead of an unrelated small model', async () => { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ + choices: [{ message: { content: 'Use the selected provider default.' } }] + }), { status: 200 }) + ) + vi.stubGlobal('fetch', fetchMock) + const settings = createSettings({ + providerId: 'deepseek', + smallModelProviderId: 'deepseek', + smallModel: 'deepseek-v4-flash', + promptOptimization: { + enabled: true, + providerId: 'other', + model: '', + prompt: '', + timeoutMs: 60000 + } + }) + settings.provider.providers.push({ + id: 'other', + name: 'Other', + apiKey: 'sk-other', + baseUrl: 'https://other.example', + endpointFormat: 'chat_completions', + models: ['other-chat'], + modelProfiles: {} + }) + + const result = await optimizePrompt(settings, 'rewrite this') + + expect(result).toEqual({ + ok: true, + text: 'Use the selected provider default.', + model: 'other-chat', + providerId: 'other' + }) + expect(fetchMock).toHaveBeenCalledWith( + 'https://other.example/v1/chat/completions', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer sk-other' + }) + }) + ) + const firstCall = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + const body = JSON.parse(String(firstCall[1].body)) as { model: string } + expect(body.model).toBe('other-chat') + }) +}) diff --git a/src/main/services/prompt-optimization-service.ts b/src/main/services/prompt-optimization-service.ts new file mode 100644 index 000000000..2351384af --- /dev/null +++ b/src/main/services/prompt-optimization-service.ts @@ -0,0 +1,245 @@ +import { + DEFAULT_DEEPSEEK_BASE_URL, + getModelProviderProfile, + modelEndpointPath, + modelProviderModelProfile, + resolveKunPromptOptimizationPrompt, + resolveKunRuntimeSettings, + resolveModelEndpointFormat, + resolveModelProviderProxyUrl, + isCustomModelEndpointFormat, + type AppSettingsV1, + type ModelEndpointFormat, + type ModelProviderProfileV1 +} from '../../shared/app-settings' +import type { PromptOptimizationResult } from '../../shared/kun-gui-api' +import { fetchWithOptionalProxy } from '../proxy-fetch' + +type PromptOptimizationRequestPayload = { + url: string + endpointFormat: ModelEndpointFormat + headers: Record + body: Record +} + +const DEFAULT_MAX_OUTPUT_TOKENS = 1600 + +function buildModelEndpointUrl(baseUrl: string, endpointFormat: ModelEndpointFormat): string { + if (isCustomModelEndpointFormat(endpointFormat)) return exactModelEndpointUrl(baseUrl) + const path = modelEndpointPath(endpointFormat) + const normalized = baseUrl.replace(/\/+$/, '') + if (!normalized) return `/v1/${path}` + if (normalized.endsWith('/v1')) return `${normalized}/${path}` + if (normalized.endsWith('/beta')) return `${normalized.slice(0, -5)}/v1/${path}` + return `${normalized}/v1/${path}` +} + +function exactModelEndpointUrl(baseUrl: string): string { + const trimmed = baseUrl.trim() + const query = trimmed.search(/[?#]/) + if (query < 0) return trimmed.replace(/\/+$/, '') + return `${trimmed.slice(0, query).replace(/\/+$/, '')}${trimmed.slice(query)}` +} + +function firstProviderModel(provider: ModelProviderProfileV1): string { + return provider.models.map((item) => item.trim()).find(Boolean) ?? '' +} + +function defaultPromptOptimizationModel( + runtime: ReturnType, + provider: ModelProviderProfileV1 +): string { + const smallModel = runtime.smallModel?.trim() ?? '' + const smallProviderId = runtime.smallModelProviderId?.trim() || runtime.providerId.trim() || provider.id + if (smallModel && smallProviderId === provider.id) return smallModel + + const mainModel = runtime.model.trim() + const mainProviderId = runtime.providerId.trim() || provider.id + if (mainModel && mainProviderId === provider.id) return mainModel + + return firstProviderModel(provider) || mainModel +} + +function effectivePromptOptimizationModel(settings: AppSettingsV1): { + providerId: string + model: string + apiKey: string + baseUrl: string + endpointFormat: ModelEndpointFormat + systemPrompt: string + timeoutMs: number +} { + const runtime = resolveKunRuntimeSettings(settings) + const promptOptimization = runtime.promptOptimization + const providerId = promptOptimization.providerId.trim() || runtime.providerId + const provider = getModelProviderProfile(settings, providerId) + const model = promptOptimization.model.trim() || defaultPromptOptimizationModel(runtime, provider) + const profile = modelProviderModelProfile(provider, model) + const endpointFormat = profile?.endpointFormat ?? provider.endpointFormat + return { + providerId: provider.id, + model, + apiKey: provider.apiKey.trim() || runtime.apiKey.trim(), + baseUrl: provider.baseUrl.trim() || runtime.baseUrl.trim() || DEFAULT_DEEPSEEK_BASE_URL, + endpointFormat, + systemPrompt: resolveKunPromptOptimizationPrompt(runtime), + timeoutMs: promptOptimization.timeoutMs + } +} + +function buildPromptOptimizationRequest(input: { + baseUrl: string + apiKey: string + endpointFormat: ModelEndpointFormat + model: string + systemPrompt: string + sourceText: string +}): PromptOptimizationRequestPayload | null { + const endpointFormat = resolveModelEndpointFormat(input.endpointFormat, input.baseUrl) + if (!endpointFormat) return null + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${input.apiKey}` + } + if (endpointFormat === 'messages') { + headers['x-api-key'] = input.apiKey + headers['anthropic-version'] = '2023-06-01' + } + if (endpointFormat === 'responses') { + return { + url: buildModelEndpointUrl(input.baseUrl, input.endpointFormat), + endpointFormat, + headers, + body: { + model: input.model, + instructions: input.systemPrompt, + input: input.sourceText, + max_output_tokens: DEFAULT_MAX_OUTPUT_TOKENS + } + } + } + if (endpointFormat === 'messages') { + return { + url: buildModelEndpointUrl(input.baseUrl, input.endpointFormat), + endpointFormat, + headers, + body: { + model: input.model, + system: input.systemPrompt, + messages: [{ role: 'user', content: input.sourceText }], + max_tokens: DEFAULT_MAX_OUTPUT_TOKENS + } + } + } + return { + url: buildModelEndpointUrl(input.baseUrl, input.endpointFormat), + endpointFormat, + headers, + body: { + model: input.model, + messages: [ + { role: 'system', content: input.systemPrompt }, + { role: 'user', content: input.sourceText } + ], + max_tokens: DEFAULT_MAX_OUTPUT_TOKENS + } + } +} + +function extractPromptOptimizationContent(rawJson: string, endpointFormat: ModelEndpointFormat): string { + const parsed = JSON.parse(rawJson) as Record + if (endpointFormat === 'responses') { + if (typeof parsed.output_text === 'string') return parsed.output_text.trim() + const output = parsed.output + if (!Array.isArray(output)) return '' + return output.map((item) => { + if (!item || typeof item !== 'object') return '' + const content = (item as { content?: unknown }).content + if (!Array.isArray(content)) return '' + return content.map((block) => { + if (!block || typeof block !== 'object') return '' + const text = (block as { text?: unknown }).text + if (typeof text === 'string') return text + const outputText = (block as { output_text?: unknown }).output_text + return typeof outputText === 'string' ? outputText : '' + }).join('') + }).join('').trim() + } + if (endpointFormat === 'messages') { + const content = parsed.content + if (!Array.isArray(content)) return '' + return content.map((block) => + block && typeof block === 'object' && typeof (block as { text?: unknown }).text === 'string' + ? (block as { text: string }).text + : '' + ).join('').trim() + } + const choices = parsed.choices + if (!Array.isArray(choices)) return '' + const first = choices[0] + return first && typeof first === 'object' + ? String((first as { message?: { content?: unknown } }).message?.content ?? '').trim() + : '' +} + +export async function optimizePrompt( + settings: AppSettingsV1, + sourceText: string +): Promise { + const trimmed = sourceText.trim() + if (!trimmed) return { ok: false, message: 'Prompt text is empty.' } + const modelSettings = effectivePromptOptimizationModel(settings) + if (!resolveKunRuntimeSettings(settings).promptOptimization.enabled) { + return { ok: false, message: 'Prompt optimization is disabled.' } + } + if (!modelSettings.apiKey) { + return { ok: false, message: 'Prompt optimization model is missing an API key.' } + } + const request = buildPromptOptimizationRequest({ + baseUrl: modelSettings.baseUrl, + apiKey: modelSettings.apiKey, + endpointFormat: modelSettings.endpointFormat, + model: modelSettings.model, + systemPrompt: modelSettings.systemPrompt, + sourceText: trimmed + }) + if (!request) return { ok: false, message: 'Prompt optimization endpoint format is invalid.' } + + let response: Response + let bodyText = '' + try { + response = await fetchWithOptionalProxy(request.url, { + method: 'POST', + headers: request.headers, + body: JSON.stringify(request.body), + signal: AbortSignal.timeout(modelSettings.timeoutMs) + }, resolveModelProviderProxyUrl(settings)) + bodyText = await response.text() + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error) + } + } + if (!response.ok) { + return { + ok: false, + message: `Prompt optimization request failed with HTTP ${response.status}: ${bodyText.slice(0, 300)}` + } + } + try { + const optimized = extractPromptOptimizationContent(bodyText, request.endpointFormat).trim() + if (!optimized) return { ok: false, message: 'Prompt optimization returned empty text.' } + return { + ok: true, + text: optimized, + model: modelSettings.model, + providerId: modelSettings.providerId + } + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error) + } + } +} diff --git a/src/main/services/write-retrieval-service.test.ts b/src/main/services/write-retrieval-service.test.ts index 2cf20cee5..3f458ebad 100644 --- a/src/main/services/write-retrieval-service.test.ts +++ b/src/main/services/write-retrieval-service.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -80,8 +80,17 @@ function createRequest(workspaceRoot: string): WriteInlineCompletionRequest { } } -afterEach(() => { +const tempRoots: string[] = [] + +async function createTempWorkspace(): Promise { + const root = await mkdtemp(join(tmpdir(), 'ds-gui-write-rag-')) + tempRoots.push(root) + return root +} + +afterEach(async () => { clearWriteRetrievalCache() + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) describe('write retrieval service', () => { @@ -95,7 +104,7 @@ describe('write retrieval service', () => { }) it('retrieves relevant cross-document snippets and excludes the active file', async () => { - const workspaceRoot = await mkdtemp(join(tmpdir(), 'ds-gui-write-rag-')) + const workspaceRoot = await createTempWorkspace() await mkdir(join(workspaceRoot, 'research'), { recursive: true }) await writeFile( join(workspaceRoot, 'draft.md'), @@ -127,7 +136,7 @@ describe('write retrieval service', () => { }) it('ignores unsupported large data files while scanning the workspace', async () => { - const workspaceRoot = await mkdtemp(join(tmpdir(), 'ds-gui-write-rag-')) + const workspaceRoot = await createTempWorkspace() await writeFile(join(workspaceRoot, 'draft.md'), '# Draft\n\nembedding cache', 'utf8') await writeFile( join(workspaceRoot, 'notes.md'), @@ -155,7 +164,7 @@ describe('write retrieval service', () => { }) it('retrieves PDF chunks for assistant context with page locations', async () => { - const workspaceRoot = await mkdtemp(join(tmpdir(), 'ds-gui-write-pdf-rag-')) + const workspaceRoot = await createTempWorkspace() const pdfPath = join(workspaceRoot, 'papers', 'study.pdf') await mkdir(join(workspaceRoot, 'papers'), { recursive: true }) await writeFile(join(workspaceRoot, 'draft.md'), '# Draft\n\nExplain literature context.', 'utf8') @@ -182,4 +191,42 @@ describe('write retrieval service', () => { }) expect(result?.snippets[0].text).toContain('PDF BM25 关键词检索') }) + + it('evicts the least recently used workspace index at the cache limit', async () => { + const first = await createTempWorkspace() + await writeFile( + join(first, 'notes.md'), + 'alphacachemarker provides enough document text for workspace retrieval indexing.', + 'utf8' + ) + const request = (workspaceRoot: string, query: string) => retrieveWriteContext({ + workspaceRoot, + currentFilePath: join(workspaceRoot, 'draft.md'), + query, + includeCurrentFile: true + }) + expect(await request(first, 'alphacachemarker')).not.toBeNull() + + const others: string[] = [] + for (let index = 0; index < 7; index += 1) { + const root = await createTempWorkspace() + others.push(root) + await request(root, 'empty') + } + expect(await request(first, 'alphacachemarker')).not.toBeNull() + await request(await createTempWorkspace(), 'overflow') + + await writeFile( + join(others[0]!, 'notes.md'), + 'betacachemarker provides enough document text for workspace retrieval indexing.', + 'utf8' + ) + expect(await request(others[0]!, 'betacachemarker')).not.toBeNull() + await writeFile( + join(first, 'notes.md'), + 'betacachemarker provides enough document text for workspace retrieval indexing.', + 'utf8' + ) + expect(await request(first, 'betacachemarker')).toBeNull() + }, 10_000) }) diff --git a/src/main/services/write-retrieval-service.ts b/src/main/services/write-retrieval-service.ts index adf2257e0..61d833f9d 100644 --- a/src/main/services/write-retrieval-service.ts +++ b/src/main/services/write-retrieval-service.ts @@ -12,6 +12,7 @@ import { expandHomePath } from './workspace-service' import { readWritePdfText, type WritePdfTextPage } from './write-pdf-text-service' const INDEX_CACHE_TTL_MS = 30_000 +const INDEX_CACHE_MAX_ENTRIES = 8 const MAX_INDEX_BUILD_MS = 250 const MAX_ASSISTANT_INDEX_BUILD_MS = 2_500 const MAX_SCAN_ENTRIES = 8_000 @@ -126,6 +127,17 @@ type QueryModel = { const indexCache = new Map() const inFlightIndexCache = new Map>() +function pruneIndexCache(now: number): void { + for (const [key, index] of indexCache) { + if (now - index.builtAt > INDEX_CACHE_TTL_MS) indexCache.delete(key) + } + while (indexCache.size > INDEX_CACHE_MAX_ENTRIES) { + const oldest = indexCache.keys().next().value + if (oldest === undefined) break + indexCache.delete(oldest) + } +} + type WorkspaceIndexOptions = { includePdf: boolean buildMs: number @@ -454,14 +466,21 @@ async function loadWorkspaceIndex( options: WorkspaceIndexOptions ): Promise { const cacheKey = workspaceIndexCacheKey(workspaceRoot, options.includePdf) + pruneIndexCache(Date.now()) const cached = indexCache.get(cacheKey) - if (cached && Date.now() - cached.builtAt <= INDEX_CACHE_TTL_MS) return cached + if (cached) { + indexCache.delete(cacheKey) + indexCache.set(cacheKey, cached) + return cached + } const existing = inFlightIndexCache.get(cacheKey) if (existing) return existing const build = buildWorkspaceIndex(workspaceRoot, options) .then((index) => { + indexCache.delete(cacheKey) indexCache.set(cacheKey, index) + pruneIndexCache(Date.now()) return index }) .finally(() => { diff --git a/src/main/telegram-runtime.ts b/src/main/telegram-runtime.ts index 6cd915b66..fda944521 100644 --- a/src/main/telegram-runtime.ts +++ b/src/main/telegram-runtime.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto' -import { mkdir, writeFile, realpath } from 'node:fs/promises' +import { mkdir, readFile, realpath, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { net } from 'electron' import type { AppSettingsV1, ClawImChannelV1 } from '../shared/app-settings' import type { ClawImTelegramConnectErrorCode } from '../shared/kun-gui-api' @@ -192,6 +192,30 @@ class TelegramChannel { return { ok: true, messageId: lastMessageId } } + async sendFile( + chatId: string, + filePath: string, + fileName?: string + ): Promise<{ ok: true; messageId?: number } | { ok: false; message: string }> { + try { + const buffer = await readFile(filePath) + const form = new FormData() + form.append('chat_id', chatId) + form.append('document', new Blob([new Uint8Array(buffer)]), fileName?.trim() || basename(filePath) || 'attachment') + const res = await telegramFetch(`${TELEGRAM_API_BASE}/bot${this.token}/sendDocument`, { + method: 'POST', + body: form, + signal: this.abort?.signal + }) + const data = (await res.json().catch(() => null)) as TelegramApiResponse<{ message_id: number }> | null + if (!data) return { ok: false, message: `HTTP ${res.status}: empty body` } + if (!data.ok) return { ok: false, message: data.description || `HTTP ${res.status}` } + return { ok: true, messageId: data.result?.message_id } + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) } + } + } + private async pollLoop(): Promise { while (this.running) { const controller = new AbortController() @@ -425,6 +449,8 @@ export type TelegramRuntime = { has(channelId: string): boolean /** Sends a text reply through the channel owning this bot. */ sendMessage(channelId: string, chatId: string, text: string): Promise<{ ok: true } | { ok: false; message: string }> + /** Sends a local file through the channel owning this bot. */ + sendFile(channelId: string, chatId: string, filePath: string, fileName?: string): Promise<{ ok: true } | { ok: false; message: string }> } /** @@ -579,6 +605,22 @@ export function createTelegramRuntime(deps: TelegramRuntimeDeps): TelegramRuntim }) } return result + }, + + async sendFile(channelId, chatId, filePath, fileName): Promise<{ ok: true } | { ok: false; message: string }> { + const channel = channels.get(channelId) + if (!channel) return { ok: false, message: 'Telegram channel is not connected.' } + const result = await channel.sendFile(chatId, filePath, fileName) + if (!result.ok) { + deps.logError('claw-telegram', 'Failed to send a Telegram file attachment.', { + channelId, + chatId, + filePath, + fileName, + message: result.message + }) + } + return result } } } diff --git a/src/main/weixin-bridge-runtime.test.ts b/src/main/weixin-bridge-runtime.test.ts index 64144666a..5183de3a7 100644 --- a/src/main/weixin-bridge-runtime.test.ts +++ b/src/main/weixin-bridge-runtime.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { createRequire } from 'node:module' -import { weixinBridgeRuntimeInternals } from './weixin-bridge-runtime' +import { + configureWeixinBridgeRuntimeContextProvider, + weixinBridgeRuntimeInternals +} from './weixin-bridge-runtime' vi.mock('electron', () => ({ app: { @@ -62,4 +65,37 @@ describe('weixin bridge runtime', () => { expect(webhookGeneratedFiles({ ok: true, reply: 'no files' })).toEqual([]) expect(webhookGeneratedFiles({ files: 'not-an-array' })).toEqual([]) }) + + it('keeps a webhook failure reply deliverable for WeChat', async () => { + configureWeixinBridgeRuntimeContextProvider(async () => ({ + webhookUrl: 'http://127.0.0.1:18787/claw/im', + webhookSecret: 'secret', + channelId: 'channel_weixin' + })) + const responseBody = { + ok: false, + message: 'Kun: model request failed: fetch failed', + reply: 'Kun: model request failed: fetch failed' + } + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 500, + json: async () => responseBody, + text: async () => JSON.stringify(responseBody) + } as Response) + + try { + await expect(weixinBridgeRuntimeInternals.postToDeepSeekGuiWebhook({ + message_id: 'wx_msg_1', + from_user_id: 'wx_user_1', + item_list: [{ type: 1, text_item: { text: '你好' } }] + }, 'wx_account_1')).resolves.toMatchObject({ + ok: false, + reply: 'Kun: model request failed: fetch failed' + }) + } finally { + fetchMock.mockRestore() + configureWeixinBridgeRuntimeContextProvider(null) + } + }) }) diff --git a/src/main/weixin-bridge-runtime.ts b/src/main/weixin-bridge-runtime.ts index dd1036ba5..3685a98f8 100644 --- a/src/main/weixin-bridge-runtime.ts +++ b/src/main/weixin-bridge-runtime.ts @@ -914,6 +914,8 @@ async function postToDeepSeekGuiWebhook(message: WeixinMessage, accountId: strin signal: AbortSignal.timeout(650_000) }) const data = await readJsonResponse(res) + const reply = recordString(data, 'reply') || recordString(data, 'text') + if (reply) return data if (!res.ok || data.ok === false) { throw new Error(recordString(data, 'message') || `Kun webhook HTTP ${res.status}`) } @@ -1209,14 +1211,16 @@ export async function getWeixinBridgeAccountUserId(accountId: string): Promise { const accountId = normalizeAccountId(options.accountId) const to = options.to.trim() - const text = options.text.trim() + const text = options.text?.trim() ?? '' + const files = options.files ?? [] if (!accountId) return { ok: false, message: 'WeChat account id is missing.' } if (!to) return { ok: false, message: 'WeChat recipient is missing.' } - if (!text) return { ok: false, message: 'Message is empty.' } + if (!text && files.length === 0) return { ok: false, message: 'Message is empty.' } try { await ensureWeixinBridgeRpcUrl() @@ -1227,13 +1231,21 @@ export async function sendWeixinBridgeMessage(options: { return { ok: false as const, message: 'WeChat account is not configured.' } } await restoreContextTokens(account.accountId) - const result = await sendMessageWeixin({ - account, - to, - text, - contextToken: getContextToken(account.accountId, to) - }) - return { ok: true as const, messageId: result.messageId } + const contextToken = getContextToken(account.accountId, to) + let messageId = '' + if (text) { + const result = await sendMessageWeixin({ + account, + to, + text, + contextToken + }) + messageId = result.messageId + } + if (files.length > 0) { + await sendGeneratedFilesWeixin(account, to, files, contextToken) + } + return { ok: true as const, messageId } } catch (error) { const message = error instanceof Error ? error.message : String(error) logError('weixin-bridge', 'Failed to send WeChat message from GUI.', { @@ -1258,5 +1270,6 @@ export function stopWeixinBridgeRuntime(): void { export const weixinBridgeRuntimeInternals = { buildBaseInfo, normalizeAccountId, + postToDeepSeekGuiWebhook, webhookGeneratedFiles } diff --git a/src/preload/index.ts b/src/preload/index.ts index b369a3676..964032b51 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -34,6 +34,7 @@ const api = { restartRuntime: () => ipcRenderer.invoke('runtime:restart'), fetchUpstreamModels: () => ipcRenderer.invoke('upstream:models'), probeModelProvider: (payload) => ipcRenderer.invoke('provider:probe', payload), + optimizePrompt: (payload) => ipcRenderer.invoke('prompt:optimize', payload), getClawStatus: () => ipcRenderer.invoke('claw:status'), runClawTask: (taskId) => ipcRenderer.invoke('claw:task:run', taskId), diff --git a/src/renderer/src/agent/kun-contract.ts b/src/renderer/src/agent/kun-contract.ts index e60070a45..658f874e9 100644 --- a/src/renderer/src/agent/kun-contract.ts +++ b/src/renderer/src/agent/kun-contract.ts @@ -393,6 +393,7 @@ export type CoreChildRuntimeMetadataJson = { childLabel?: string childStatus: 'queued' | 'running' | 'completed' | 'failed' | 'aborted' childSeq: number + detached?: boolean childModel?: string childProfile?: string childToolPolicy?: 'readOnly' | 'inherit' @@ -447,6 +448,7 @@ export type CoreTurnItemJson = { kind: string text?: string displayText?: string + messageSource?: 'background_shell' | 'background_subagent' toolName?: string callId?: string toolKind?: 'tool_call' | 'command_execution' | 'file_change' @@ -612,11 +614,14 @@ export type CoreRuntimeEventJson = { callId?: string readyCount?: number toolResultCount?: number - fingerprint?: string - toolCount?: number - changeKind?: 'additive' | 'breaking' - toolNames?: string[] - status?: string + attempt?: number + maxAttempts?: number + delayMs?: number + fingerprint?: string + toolCount?: number + changeKind?: 'additive' | 'breaking' + toolNames?: string[] + status?: string | number /** thread_created / thread_updated: the thread's (possibly upgraded) title. */ title?: string /** thread_created / thread_updated: whether that title is auto/provisional. */ diff --git a/src/renderer/src/agent/kun-mapper.test.ts b/src/renderer/src/agent/kun-mapper.test.ts index b934bb4f4..6f0c48f5d 100644 --- a/src/renderer/src/agent/kun-mapper.test.ts +++ b/src/renderer/src/agent/kun-mapper.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { chatBlockFromItem, dispatchKunRuntimeEvent, mergeChatBlocks } from './kun-mapper' import type { CoreRuntimeEventJson, CoreTurnItemJson } from './kun-contract' import type { ThreadErrorOptions, ThreadEventSink } from './types' @@ -213,6 +213,134 @@ describe('create_plan tool mapping', () => { expect(capturedErrorOptions).toEqual({ terminal: true }) }) + it('does not finish the parent turn for child lifecycle events', async () => { + let completed = 0 + let fatalErrors = 0 + let childUpdate: unknown = null + const sink: ThreadEventSink = { + ...makeSink(), + onTurnComplete: () => { + completed += 1 + }, + onTool: (event) => { + childUpdate = event + }, + onError: () => { + fatalErrors += 1 + } + } + const child = { + parentThreadId: 'thr_1', + parentTurnId: 'turn_1', + childId: 'child_1', + childLabel: 'child', + childStatus: 'completed' as const, + childSeq: 1, + detached: true + } + + await dispatchKunRuntimeEvent({ + kind: 'turn_started', + seq: 8, + timestamp: '2024-01-01T00:00:00.000Z', + threadId: 'thr_1', + turnId: 'turn_1', + child: { ...child, childStatus: 'running' } + }, sink, async () => undefined) + expect(childUpdate).toMatchObject({ + status: 'running', + updateOnly: true, + meta: { + child: { + childId: 'child_1', + childStatus: 'running', + detached: true + } + } + }) + + await dispatchKunRuntimeEvent({ + kind: 'turn_completed', + seq: 9, + timestamp: '2024-01-01T00:00:00.000Z', + threadId: 'thr_1', + turnId: 'turn_1', + child + }, sink, async () => undefined) + await dispatchKunRuntimeEvent({ + kind: 'turn_aborted', + seq: 10, + timestamp: '2024-01-01T00:00:01.000Z', + threadId: 'thr_1', + turnId: 'turn_1', + child: { ...child, childStatus: 'aborted' } + }, sink, async () => undefined) + await dispatchKunRuntimeEvent({ + kind: 'turn_failed', + seq: 11, + timestamp: '2024-01-01T00:00:02.000Z', + threadId: 'thr_1', + turnId: 'turn_1', + child: { ...child, childStatus: 'failed' }, + message: 'child failed' + }, sink, async () => undefined) + + expect(completed).toBe(0) + expect(fatalErrors).toBe(0) + expect(childUpdate).toMatchObject({ + status: 'error', + updateOnly: true, + meta: { + child: { + childId: 'child_1', + childStatus: 'failed', + detached: true + } + } + }) + }) + + it('keeps detached delegate_task results running until the child settles', async () => { + let captured: unknown = null + const sink: ThreadEventSink = { + ...makeSink(), + onTool: (event) => { + captured = event + } + } + + await dispatchKunRuntimeEvent({ + kind: 'item_completed', + seq: 12, + item: { + id: 'item_delegate', + turnId: 'turn_1', + threadId: 'thr_1', + role: 'tool', + status: 'completed', + createdAt: '2024-01-01T00:00:00.000Z', + kind: 'tool_result', + toolName: 'delegate_task', + callId: 'call_delegate', + output: { + childId: 'child_background', + status: 'queued', + detached: true + } + } + }, sink, async () => undefined) + + expect(captured).toMatchObject({ + itemId: 'tool_call_delegate', + status: 'running' + }) + expect(JSON.parse((captured as { detail: string }).detail)).toMatchObject({ + childId: 'child_background', + status: 'queued', + detached: true + }) + }) + it('routes live error items to runtime error timeline events without fatal stream errors', async () => { let fatalCalled = false let capturedRuntimeError: unknown = null @@ -825,6 +953,46 @@ describe('streaming runtime status events', () => { message: 'read repeated the same arguments' }) }) + + it('surfaces model request retries as runtime status events', async () => { + let captured: unknown = null + const runtimeError = vi.fn() + const sink: ThreadEventSink = { + ...makeSink(), + onRuntimeStatus: (event) => { + captured = event + }, + onRuntimeError: runtimeError + } + + await dispatchKunRuntimeEvent( + { + kind: 'model_request_retry', + seq: 24, + timestamp: '2026-06-03T10:00:03.000Z', + threadId: 'thr_1', + turnId: 'turn_1', + status: 429, + attempt: 1, + maxAttempts: 3, + delayMs: 3000 + }, + sink, + async () => undefined + ) + + expect(captured).toMatchObject({ + kind: 'model_request_retry', + itemId: 'runtime_status_turn_1_model_retry', + turnId: 'turn_1', + createdAt: '2026-06-03T10:00:03.000Z', + status: 429, + attempt: 1, + maxAttempts: 3, + delayMs: 3000 + }) + expect(runtimeError).not.toHaveBeenCalled() + }) }) describe('Kun extension metadata mapping', () => { @@ -921,6 +1089,29 @@ describe('Kun extension metadata mapping', () => { } }) }) + + it('preserves background subagent message source on user messages', () => { + const block = chatBlockFromItem({ + id: 'item_subagent_notice', + turnId: 'turn_1', + threadId: 'thr_1', + role: 'user', + status: 'completed', + createdAt: '2024-01-01T00:00:00.000Z', + kind: 'user_message', + text: 'child-1completeddone', + displayText: 'Background subagent 后台休眠 completed', + messageSource: 'background_subagent' + }) + + expect(block).toMatchObject({ + kind: 'user', + meta: { + displayText: 'Background subagent 后台休眠 completed', + messageSource: 'background_subagent' + } + }) + }) }) describe('usage event mapping', () => { diff --git a/src/renderer/src/agent/kun-mapper.ts b/src/renderer/src/agent/kun-mapper.ts index 6cde83cca..87eafe09f 100644 --- a/src/renderer/src/agent/kun-mapper.ts +++ b/src/renderer/src/agent/kun-mapper.ts @@ -233,7 +233,17 @@ function normalizeChildMetadata( ...(child.childModel ? { childModel: child.childModel } : {}), ...(child.childToolPolicy ? { childToolPolicy: child.childToolPolicy } : {}), childStatus: child.childStatus, - childSeq: child.childSeq + childSeq: child.childSeq, + ...(child.detached !== undefined ? { detached: child.detached } : {}), + ...(child.prefixReused !== undefined ? { prefixReused: child.prefixReused } : {}), + ...(child.inheritedHistoryItems !== undefined ? { inheritedHistoryItems: child.inheritedHistoryItems } : {}), + ...(child.toolInvocations !== undefined ? { toolInvocations: child.toolInvocations } : {}), + ...(child.durationMs !== undefined ? { durationMs: child.durationMs } : {}), + ...(child.queuedMs !== undefined ? { queuedMs: child.queuedMs } : {}), + ...(child.totalTokens !== undefined ? { totalTokens: child.totalTokens } : {}), + ...(child.cacheHitRate !== undefined ? { cacheHitRate: child.cacheHitRate } : {}), + ...(child.costUsd !== undefined ? { costUsd: child.costUsd } : {}), + ...(child.costCny !== undefined ? { costCny: child.costCny } : {}) } } @@ -315,6 +325,9 @@ function applyRuntimeDisclosureMeta( if (displayText && displayText !== item.text?.trim()) { meta.displayText = displayText } + if (item.messageSource === 'background_shell' || item.messageSource === 'background_subagent') { + meta.messageSource = item.messageSource + } applyClientUserMessageSourceMeta(meta, item.text ?? '') if (attachmentIds) meta.attachmentIds = attachmentIds if (fileReferences) meta.fileReferences = fileReferences @@ -604,7 +617,7 @@ function toolBlockFromItem(item: CoreTurnItemJson, child?: CoreChildRuntimeMetad id: toolBlockId(item), createdAt: itemCreatedAt(item), summary, - status: toolStatus(item), + status: delegateTaskStatusOverride(item, payload) ?? toolStatus(item), toolKind: presentation.toolKind, ...(presentation.filePath ? { filePath: presentation.filePath } : {}), ...(detail ? { detail } : {}), @@ -612,6 +625,18 @@ function toolBlockFromItem(item: CoreTurnItemJson, child?: CoreChildRuntimeMetad } } +function delegateTaskStatusOverride( + item: CoreTurnItemJson, + payload: Record +): ToolBlock['status'] | undefined { + if (item.toolName !== 'delegate_task' || payload.detached !== true) return undefined + const childStatus = typeof payload.status === 'string' ? payload.status : undefined + if (childStatus === 'queued' || childStatus === 'running') return 'running' + if (childStatus === 'failed' || childStatus === 'aborted') return 'error' + if (childStatus === 'completed') return 'success' + return undefined +} + export function mergeChatBlocks(blocks: ChatBlock[]): ChatBlock[] { const merged: ChatBlock[] = [] const toolIndexes = new Map() @@ -1012,6 +1037,31 @@ function toolEventFromItem(item: CoreTurnItemJson, child?: CoreChildRuntimeMetad } } +function toolStatusFromChildStatus(status: CoreChildRuntimeMetadataJson['childStatus']): ToolEventPayload['status'] { + if (status === 'queued' || status === 'running') return 'running' + if (status === 'completed') return 'success' + return 'error' +} + +function childLifecycleToolEventFromRuntimeEvent(event: CoreRuntimeEventJson): ToolEventPayload | null { + const child = normalizeChildMetadata(event.child) + if (!child) return null + return { + itemId: `child_lifecycle_${child.childId}`, + summary: child.childLabel || 'delegate_task', + status: toolStatusFromChildStatus(child.childStatus), + updateOnly: true, + createdAt: event.timestamp, + toolKind: 'tool_call', + detail: JSON.stringify({ + childId: child.childId, + status: child.childStatus, + detached: child.detached === true + }), + meta: { child } + } +} + function compactionFromItem(item: CoreTurnItemJson): CompactionEventPayload { return { itemId: item.id, @@ -1142,33 +1192,46 @@ function runtimeStatusFromEvent(event: CoreRuntimeEventJson): RuntimeStatusEvent toolResultCount: typeof event.toolResultCount === 'number' ? event.toolResultCount : 0 } } - if (event.kind === 'tool_catalog_changed') { - const key = event.fingerprint ?? event.seq ?? Date.now() - return { - kind: 'tool_catalog_changed', - itemId: `runtime_status_tool_catalog_${key}`, - turnId: event.turnId, - createdAt: event.timestamp, - ...(event.changeKind ? { changeKind: event.changeKind } : {}), - message: event.message - } - } - if (event.kind === 'tool_storm_suppressed') { - const callId = typeof event.callId === 'string' && event.callId.trim() ? event.callId.trim() : '' - const toolName = typeof event.toolName === 'string' && event.toolName.trim() ? event.toolName.trim() : '' - if (!callId || !toolName) return null - return { - kind: 'tool_storm_suppressed', - itemId: event.itemId ?? `runtime_status_tool_storm_${callId}`, - turnId: event.turnId, - createdAt: event.timestamp, - message: event.message, - toolName, - callId - } - } - return null - } + if (event.kind === 'model_request_retry') { + const turnKey = event.turnId ?? event.threadId ?? event.seq ?? Date.now() + return { + kind: 'model_request_retry', + itemId: `runtime_status_${turnKey}_model_retry`, + turnId: event.turnId, + createdAt: event.timestamp, + status: typeof event.status === 'number' ? event.status : undefined, + attempt: typeof event.attempt === 'number' ? event.attempt : undefined, + maxAttempts: typeof event.maxAttempts === 'number' ? event.maxAttempts : undefined, + delayMs: typeof event.delayMs === 'number' ? event.delayMs : undefined + } + } + if (event.kind === 'tool_catalog_changed') { + const key = event.fingerprint ?? event.seq ?? Date.now() + return { + kind: 'tool_catalog_changed', + itemId: `runtime_status_tool_catalog_${key}`, + turnId: event.turnId, + createdAt: event.timestamp, + ...(event.changeKind ? { changeKind: event.changeKind } : {}), + message: event.message + } + } + if (event.kind === 'tool_storm_suppressed') { + const callId = typeof event.callId === 'string' && event.callId.trim() ? event.callId.trim() : '' + const toolName = typeof event.toolName === 'string' && event.toolName.trim() ? event.toolName.trim() : '' + if (!callId || !toolName) return null + return { + kind: 'tool_storm_suppressed', + itemId: event.itemId ?? `runtime_status_tool_storm_${callId}`, + turnId: event.turnId, + createdAt: event.timestamp, + message: event.message, + toolName, + callId + } + } + return null +} /** * Dispatches a batch of runtime events, coalescing consecutive text and @@ -1223,6 +1286,12 @@ export async function dispatchKunRuntimeEvent( case 'tool_call_finished': if (event.item) emitItem(event.item, sink, event.child) return + case 'turn_started': + if (event.child) { + const tool = childLifecycleToolEventFromRuntimeEvent(event) + if (tool) sink.onTool(tool) + } + return case 'tool_call_ready': { const tool = toolReadyFromEvent(event) if (tool) sink.onTool(tool) @@ -1233,16 +1302,13 @@ export async function dispatchKunRuntimeEvent( if (status) sink.onRuntimeStatus?.(status) return } - case 'tool_catalog_changed': { - const status = runtimeStatusFromEvent(event) - if (status) sink.onRuntimeStatus?.(status) - return - } - case 'tool_storm_suppressed': { - const status = runtimeStatusFromEvent(event) - if (status) sink.onRuntimeStatus?.(status) - return - } + case 'model_request_retry': + case 'tool_catalog_changed': + case 'tool_storm_suppressed': { + const status = runtimeStatusFromEvent(event) + if (status) sink.onRuntimeStatus?.(status) + return + } case 'approval_requested': await handleApprovalRequest(event, sink) return @@ -1307,14 +1373,24 @@ export async function dispatchKunRuntimeEvent( threadId: event.threadId ?? '', ...(event.title !== undefined ? { title: event.title } : {}), ...(event.titleAuto !== undefined ? { titleAuto: event.titleAuto } : {}), - ...(event.status !== undefined ? { status: event.status } : {}) + ...(typeof event.status === 'string' ? { status: event.status } : {}) }) return case 'turn_completed': case 'turn_aborted': + if (event.child) { + const tool = childLifecycleToolEventFromRuntimeEvent(event) + if (tool) sink.onTool(tool) + return + } sink.onTurnComplete() return case 'turn_failed': { + if (event.child) { + const tool = childLifecycleToolEventFromRuntimeEvent(event) + if (tool) sink.onTool(tool) + return + } const payload = runtimeErrorFromEvent(event, 'Kun turn failed') sink.onRuntimeError?.(payload) sink.onError(errorForRuntimeEvent(payload), { terminal: true }) diff --git a/src/renderer/src/agent/kun-runtime.test.ts b/src/renderer/src/agent/kun-runtime.test.ts index d31379572..6b91d6d87 100644 --- a/src/renderer/src/agent/kun-runtime.test.ts +++ b/src/renderer/src/agent/kun-runtime.test.ts @@ -734,7 +734,8 @@ describe('KunRuntimeProvider', () => { ) }) - it('lists, disables, and deletes memory records through Kun endpoints', async () => { + it('lists, toggles, and deletes memory records through Kun endpoints', async () => { + const memoryPatches: string[] = [] const runtimeRequest = vi.fn(async (path: string, method?: string, body?: string) => { if (path === '/v1/memory?workspace=%2Ftmp%2Fworkspace&include_deleted=false') { return { @@ -755,7 +756,8 @@ describe('KunRuntimeProvider', () => { } } if (path === '/v1/memory/mem_1?workspace=%2Ftmp%2Fworkspace' && method === 'PATCH') { - expect(body).toBe(JSON.stringify({ disabled: true })) + memoryPatches.push(body ?? '') + const disabled = JSON.parse(body ?? '{}').disabled === true return { ok: true, status: 200, @@ -764,9 +766,9 @@ describe('KunRuntimeProvider', () => { id: 'mem_1', content: 'Use pnpm', scope: 'workspace', - disabledAt: 't1', + ...(disabled ? { disabledAt: 't1' } : {}), createdAt: 't0', - updatedAt: 't1' + updatedAt: disabled ? 't1' : 't2' } }) } @@ -797,6 +799,14 @@ describe('KunRuntimeProvider', () => { id: 'mem_1', disabledAt: 't1' }) + await expect(provider.updateMemory('mem_1', { disabled: false }, { workspace: '/tmp/workspace' })).resolves.toMatchObject({ + id: 'mem_1', + updatedAt: 't2' + }) + expect(memoryPatches).toEqual([ + JSON.stringify({ disabled: true }), + JSON.stringify({ disabled: false }) + ]) await expect(provider.deleteMemory('mem_1', { workspace: '/tmp/workspace' })).resolves.toMatchObject({ id: 'mem_1', deletedAt: 't2' diff --git a/src/renderer/src/agent/types.ts b/src/renderer/src/agent/types.ts index ed37d74fd..9ff8c945a 100644 --- a/src/renderer/src/agent/types.ts +++ b/src/renderer/src/agent/types.ts @@ -62,6 +62,16 @@ export type RuntimeChildMetadata = { childToolPolicy?: 'readOnly' | 'inherit' childStatus: 'queued' | 'running' | 'completed' | 'failed' | 'aborted' childSeq: number + detached?: boolean + prefixReused?: boolean + inheritedHistoryItems?: number + toolInvocations?: number + durationMs?: number + queuedMs?: number + totalTokens?: number + cacheHitRate?: number | null + costUsd?: number + costCny?: number } export type WebCitationSource = { @@ -73,7 +83,7 @@ export type WebCitationSource = { export type RuntimeDisclosureMetadata = { displayText?: string - messageSource?: 'background_shell' // client-only rendering hint; never sent to the runtime + messageSource?: 'background_shell' | 'background_subagent' // client-only rendering hint; never sent to the runtime turnId?: string workspaceCheckpointId?: string attachmentIds?: string[] @@ -325,6 +335,8 @@ export type ToolEventPayload = { itemId: string summary: string status: 'running' | 'success' | 'error' + updateOnly?: boolean + createdAt?: string toolKind?: ToolItemKind detail?: string filePath?: string @@ -334,6 +346,7 @@ export type ToolEventPayload = { export type RuntimeStatusEventPayload = { kind: | 'tool_result_upload_wait' + | 'model_request_retry' | 'tool_catalog_changed' | 'tool_storm_suppressed' | 'compaction_summary_fallback' @@ -342,6 +355,10 @@ export type RuntimeStatusEventPayload = { createdAt?: string message?: string toolResultCount?: number + status?: number + attempt?: number + maxAttempts?: number + delayMs?: number changeKind?: 'additive' | 'breaking' toolName?: string callId?: string diff --git a/src/renderer/src/components/Workbench.tsx b/src/renderer/src/components/Workbench.tsx index bff705c4c..6057cd22a 100644 --- a/src/renderer/src/components/Workbench.tsx +++ b/src/renderer/src/components/Workbench.tsx @@ -372,6 +372,7 @@ export function Workbench(): ReactElement { const { handleSend, sendWritePrompt } = useWorkbenchComposerSubmitController({ activeClawChannelId, activeClawChannelModel: activeClawChannel?.model, + activeClawChannelProviderId: activeClawChannel?.providerId, activeSddDraft: Boolean(activeSddDraft), activeThreadId, attachmentUploadEnabled, buildCodeCanvasOutboundPrompt, clearComposerAttachments, clearComposerFileReferences, composerAttachments, composerFileReferences, composerMode, composerModelGroups, diff --git a/src/renderer/src/components/chat/FloatingComposer.test.ts b/src/renderer/src/components/chat/FloatingComposer.test.ts index d249bc123..f88ba64e6 100644 --- a/src/renderer/src/components/chat/FloatingComposer.test.ts +++ b/src/renderer/src/components/chat/FloatingComposer.test.ts @@ -13,6 +13,7 @@ import { parseNewCommand, parseResearchCommand, parseReviewCommand, + shouldCaptureFileMentionCommitKey, shouldShowGoalFloater } from './FloatingComposer' import { @@ -23,6 +24,7 @@ import { composerModelMenuItemSelected, composerMenuSupportsModel, composerReasoningEffortRequestValue, + buildComposerModelOptions, canSwitchComposerModelFromCurrent, filterComposerModelIds, normalizeComposerReasoningEffort @@ -39,6 +41,7 @@ import { filterWorkspaceFileMentionSuggestions, formatComposerFileMentionToken, getFileMentionAtCursor, + hasComposerFileMentionToken, isFileWithinDirectory, removeComposerFileMentionToken, replaceFileMentionInInput, @@ -175,6 +178,33 @@ describe('FloatingComposer goal helpers', () => { }) describe('FloatingComposer file references', () => { + it('captures file mention commit keys while the menu is active before candidates load', () => { + expect(shouldCaptureFileMentionCommitKey({ + key: 'Enter', + shiftKey: false, + metaKey: false, + ctrlKey: false + })).toBe(true) + expect(shouldCaptureFileMentionCommitKey({ + key: 'Tab', + shiftKey: false, + metaKey: false, + ctrlKey: false + })).toBe(true) + expect(shouldCaptureFileMentionCommitKey({ + key: 'Enter', + shiftKey: true, + metaKey: false, + ctrlKey: false + })).toBe(false) + expect(shouldCaptureFileMentionCommitKey({ + key: 'Enter', + shiftKey: false, + metaKey: true, + ctrlKey: false + })).toBe(false) + }) + it('parses @ file mention queries at the current cursor', () => { expect(getFileMentionAtCursor('please inspect @src/ren', 'please inspect @src/ren'.length)).toEqual({ start: 15, @@ -230,6 +260,14 @@ describe('FloatingComposer file references', () => { expect(removeComposerFileMentionToken(reordered, 'src', true)).toBe('review @src/App.tsx and now') }) + it('detects exact inserted mention tokens without matching path prefixes', () => { + expect(hasComposerFileMentionToken('review @src/renderer/src/App.tsx now', 'src/renderer/src/App.tsx')).toBe(true) + expect(hasComposerFileMentionToken('review @"docs/product plan.md" now', 'docs/product plan.md')).toBe(true) + expect(hasComposerFileMentionToken('review @src/ now', 'src', true)).toBe(true) + expect(hasComposerFileMentionToken('review @src/App.tsx now', 'src', true)).toBe(false) + expect(hasComposerFileMentionToken('email test@src/App.tsx now', 'src/App.tsx')).toBe(false) + }) + it('ranks directories alongside files and favors them for trailing-slash queries', () => { const entries: ComposerFileReference[] = [ { path: '/repo/src', relativePath: 'src', name: 'src', type: 'directory' }, @@ -241,6 +279,18 @@ describe('FloatingComposer file references', () => { expect(suggestions.map((entry) => entry.relativePath)).toContain('src/App.tsx') }) + it('filters path-like @ queries and excludes already selected duplicate paths', () => { + const entries: ComposerFileReference[] = [ + { path: '/repo/src/renderer', relativePath: 'src/renderer', name: 'renderer', type: 'directory' }, + { path: '/repo/src/renderer/src/FloatingComposer.tsx', relativePath: 'src/renderer/src/FloatingComposer.tsx', name: 'FloatingComposer.tsx', type: 'file' }, + { path: '/repo/packages/ui/src/FloatingComposer.tsx', relativePath: 'packages/ui/src/FloatingComposer.tsx', name: 'FloatingComposer.tsx', type: 'file' } + ] + + const suggestions = filterWorkspaceFileMentionSuggestions(entries, 'src/ren', [entries[1]!]) + + expect(suggestions.map((entry) => entry.relativePath)).toEqual(['src/renderer']) + }) + it('lists every indexed file beneath a referenced directory', () => { const files: ComposerFileReference[] = [ { path: '/repo/src/App.tsx', relativePath: 'src/App.tsx', name: 'App.tsx', type: 'file' }, @@ -408,6 +458,16 @@ describe('FloatingComposer model controls', () => { }) }) + it('builds model picker options only from configured picks, not the current model', () => { + expect(buildComposerModelOptions([ + ' deepseek-v4-pro ', + 'mock-model', + 'deepseek-v4-pro', + ' ' + ])).toEqual(['deepseek-v4-pro', 'mock-model']) + expect(buildComposerModelOptions(['deepseek-v4-pro'])).not.toContain('stale-thread-model') + }) + it('deduplicates models within a provider but keeps the same model id across providers', () => { const groups = buildComposerModelMenuGroups({ composerModelGroups: [ diff --git a/src/renderer/src/components/chat/FloatingComposer.tsx b/src/renderer/src/components/chat/FloatingComposer.tsx index 957ddf2d2..eea07f1ed 100644 --- a/src/renderer/src/components/chat/FloatingComposer.tsx +++ b/src/renderer/src/components/chat/FloatingComposer.tsx @@ -47,10 +47,12 @@ import type { AttachmentReference, ChatBlock, ReviewTarget } from '../../agent/t import { useChatStore } from '../../store/chat-store' import { normalizeWorkspaceRoot } from '../../lib/workspace-path' import { + composerFileReferenceKey, composerFileReferenceFromPath, filterWorkspaceFileMentionSuggestions, formatComposerFileMentionToken, getFileMentionAtCursor, + hasComposerFileMentionToken, isComposerDirectoryReference, removeComposerFileMentionToken, replaceFileMentionInInput, @@ -110,7 +112,7 @@ import { } from './FloatingComposerExecutionPicker' import { ImagePreviewLightbox } from './ImagePreviewLightbox' import { useComposerDraft } from './use-composer-draft' -import { useSpeechToTextSettings, useVoiceDictation } from './use-voice-dictation' +import { usePromptOptimizationSettings, useSpeechToTextSettings, useVoiceDictation } from './use-voice-dictation' import { VoiceRecordingStrip } from './VoiceRecordingStrip' import type { ComposerChangedFile } from '../../lib/composer-change-summary' import type { DesignComposerContext } from '../../design/design-composer-context' @@ -228,6 +230,13 @@ type Props = { type SkillCommand = NonNullable[number] +export function shouldCaptureFileMentionCommitKey( + event: Pick, 'key' | 'shiftKey' | 'metaKey' | 'ctrlKey'> +): boolean { + if (event.key === 'Tab') return true + return event.key === 'Enter' && !event.shiftKey && !event.metaKey && !event.ctrlKey +} + const EMPTY_CONTEXT_BLOCKS: ChatBlock[] = [] const EMPTY_MODEL_GROUPS: ModelProviderModelGroup[] = [] const EMPTY_ATTACHMENTS: AttachmentReference[] = [] @@ -567,6 +576,7 @@ export function FloatingComposer({ const userInput = useComposerUserInput(pendingUserInputBlock, resolveUserInput) const fileInputRef = useRef(null) const speechToTextSettings = useSpeechToTextSettings() + const promptOptimizationSettings = usePromptOptimizationSettings() const dictationInputRef = useRef(input) useEffect(() => { dictationInputRef.current = input @@ -675,11 +685,14 @@ export function FloatingComposer({ const [goalPanelOpen, setGoalPanelOpen] = useState(false) const [contextCapacityOpen, setContextCapacityOpen] = useState(false) const [goalRuntimeNowMs, setGoalRuntimeNowMs] = useState(() => Date.now()) + const [promptOptimizationBusy, setPromptOptimizationBusy] = useState(false) + const [promptOptimizationError, setPromptOptimizationError] = useState(null) const composerRootRef = useRef(null) const composerMenuButtonRef = useRef(null) const composerMenuPanelRef = useRef(null) const goalPanelRef = useRef(null) const contextCapacityRef = useRef(null) + const fileMentionPresenceRef = useRef>(new Map()) const messageTokenCacheRef = useRef>(new WeakMap()) // Cache the last-known runtime capacity inputs. `runtimeInfo` (and thus these // props) goes null whenever the runtime drops/reconnects; without caching, the @@ -1027,6 +1040,13 @@ export function FloatingComposer({ ? false : !canSend const primaryActionLoading = !runtimeReady + const canOptimizePrompt = + promptOptimizationSettings?.enabled === true && + canEditComposer && + !promptOptimizationBusy && + input.trim().length > 0 && + typeof window !== 'undefined' && + typeof window.kunGui?.optimizePrompt === 'function' const goalRuntimeStartedAtMs = goalRuntimeStartedAtRef.current const liveGoalElapsedSeconds = busy && activeThreadGoal?.status === 'active' && goalRuntimeStartedAtMs != null @@ -1110,6 +1130,29 @@ export function FloatingComposer({ showFileMentionMenu ]) + useEffect(() => { + const previous = fileMentionPresenceRef.current + const next = new Map() + const removedRelativePaths: string[] = [] + + for (const reference of fileReferences) { + const isDirectory = isComposerDirectoryReference(reference) + const key = composerFileReferenceKey(reference) + const present = hasComposerFileMentionToken(input, reference.relativePath, isDirectory) + if (previous.get(key) === true && !present) { + removedRelativePaths.push(reference.relativePath) + } + next.set(key, present) + } + + fileMentionPresenceRef.current = next + + if (!onRemoveFileReference || removedRelativePaths.length === 0) return + for (const relativePath of removedRelativePaths) { + onRemoveFileReference(relativePath) + } + }, [fileReferences, input, onRemoveFileReference]) + useEffect(() => { if (!composerMenuOpen && !goalPanelOpen) return @@ -1353,6 +1396,33 @@ export function FloatingComposer({ draft.focusComposer() } + const handlePromptOptimizationClick = (): void => { + if (!canOptimizePrompt) return + const sourceText = input + setPromptOptimizationBusy(true) + setPromptOptimizationError(null) + void window.kunGui.optimizePrompt({ text: sourceText }) + .then((result) => { + if (!result.ok) { + setPromptOptimizationError(result.message) + return + } + setInput(result.text) + window.requestAnimationFrame(() => { + const textarea = draft.textareaRef.current + if (!textarea) return + textarea.focus() + const cursor = result.text.length + textarea.setSelectionRange(cursor, cursor) + setComposerCursor(cursor) + }) + }) + .catch((error) => { + setPromptOptimizationError(error instanceof Error ? error.message : String(error)) + }) + .finally(() => setPromptOptimizationBusy(false)) + } + const syncComposerCursor = (element = draft.textareaRef.current): void => { if (!element) return setComposerCursor(element.selectionStart ?? input.length) @@ -1375,6 +1445,7 @@ export function FloatingComposer({ const removeFileReference = (reference: ComposerFileReference): void => { onRemoveFileReference?.(reference.relativePath) + fileMentionPresenceRef.current.set(composerFileReferenceKey(reference), false) const nextInput = removeComposerFileMentionToken( input, reference.relativePath, @@ -1491,9 +1562,11 @@ export function FloatingComposer({ ) return } - if ((event.key === 'Enter' || event.key === 'Tab') && highlightedFileMention) { + if (shouldCaptureFileMentionCommitKey(event)) { event.preventDefault() - applyFileMention(highlightedFileMention) + if (highlightedFileMention) { + applyFileMention(highlightedFileMention) + } return } if (event.key === 'Escape') { @@ -2273,13 +2346,20 @@ export function FloatingComposer({ ) : null} + {promptOptimizationError ? ( +
+ + {promptOptimizationError} + +
+ ) : null}
{showToolbarStartControls ? ( -
+
{showComposerMenuButton ? ( <> ) : null} + {promptOptimizationSettings?.enabled === true ? ( + + ) : null} {busy ? (
- {activeProviderGroup ? ( + {reasoningPanelOpen && reasoningEnabled ? ( +
+
+ {t('composerReasoning')} +
+
+ {reasoningOptions.map((option) => ( + { + onComposerReasoningEffortChange?.(option.id) + setMenuOpen(false) + }} + /> + ))} +
+
+ ) : activeProviderGroup ? (
() + for (const id of composerPickList) { + const normalized = id.trim() + if (normalized) ordered.add(normalized) + } + return [...ordered] +} + export function filterComposerModelIds( modelIds: readonly string[], query: string @@ -788,6 +831,10 @@ function estimatedModelSubmenuHeight(modelCount: number): number { return 34 + Math.max(1, modelCount) * 36 + 12 } +function estimatedReasoningSubmenuHeight(optionCount: number): number { + return 34 + Math.max(1, optionCount) * 36 + 12 +} + function normalizeModelCapabilityKey(modelId: string): string { return modelId.trim().toLowerCase() } @@ -980,6 +1027,38 @@ function ProviderRow({ refNode: (node: HTMLButtonElement | null) => void onClick: () => void onMouseEnter: () => void +}): ReactElement { + return ( + + ) +} + +function SubmenuRow({ + active, + selected, + icon, + title, + subtitle, + refNode, + onClick, + onMouseEnter +}: { + active: boolean + selected: boolean + icon?: ReactElement | null + title: string + subtitle: string + refNode: (node: HTMLButtonElement | null) => void + onClick: () => void + onMouseEnter: () => void }): ReactElement { return ( + /> ))} ) : null} -
+
{jumpRailPreview.title}
+
{jumpRailPreview.prompt}
+
+ ) : null} +
{!hasContent || !activeThreadId ? ( diff --git a/src/renderer/src/components/chat/MessageTimeline.turn-rail.test.ts b/src/renderer/src/components/chat/MessageTimeline.turn-rail.test.ts index 20a4bdf85..16c6f0339 100644 --- a/src/renderer/src/components/chat/MessageTimeline.turn-rail.test.ts +++ b/src/renderer/src/components/chat/MessageTimeline.turn-rail.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { activeTimelineTurnKey } from './MessageTimeline' +import { + activeTimelineTurnKey, + timelineJumpRailLeft, + timelineJumpRailPreviewLeft, + timelineJumpWaveLevel +} from './MessageTimeline' describe('activeTimelineTurnKey', () => { const positions = [ @@ -23,3 +28,37 @@ describe('activeTimelineTurnKey', () => { expect(activeTimelineTurnKey([])).toBeNull() }) }) + +describe('timelineJumpWaveLevel', () => { + it('cycles compact rail items through a wave pattern', () => { + expect(Array.from({ length: 7 }, (_, index) => timelineJumpWaveLevel(index))).toEqual([2, 4, 5, 3, 1, 2, 4]) + }) +}) + +describe('timelineJumpRailLeft', () => { + it('keeps the rail beside the content when the content width is capped', () => { + expect(timelineJumpRailLeft(1000, 800)).toBe(82) + }) + + it('reserves space when the requested content width is wider than the stage', () => { + expect(timelineJumpRailLeft(1000, 1200)).toBe(24) + }) + + it('uses the measured message column when available', () => { + expect(timelineJumpRailLeft(1000, 1200, 140)).toBe(122) + }) + + it('keeps the rail inside the chat stage when measured content sits near the edge', () => { + expect(timelineJumpRailLeft(1000, 1200, 6)).toBe(16) + }) +}) + +describe('timelineJumpRailPreviewLeft', () => { + it('keeps the hover preview inside the conversation gutter', () => { + expect(timelineJumpRailPreviewLeft(-20, 520)).toBe(16) + }) + + it('keeps the hover preview inside the conversation right edge', () => { + expect(timelineJumpRailPreviewLeft(1000, 1200)).toBe(768) + }) +}) diff --git a/src/renderer/src/components/chat/Sidebar.tsx b/src/renderer/src/components/chat/Sidebar.tsx index 67cac2b45..90cc2e4fa 100644 --- a/src/renderer/src/components/chat/Sidebar.tsx +++ b/src/renderer/src/components/chat/Sidebar.tsx @@ -159,13 +159,6 @@ export function Sidebar({ ariaLabel={t('focusModeToggleLabel')} />
- } - label={t('claw')} - onClick={onToggleConnectPhone} - active={connectPhoneSidebarOpen} - variant="footer" - />
+ + + } - label={t('workflow')} + label={t('workflowCreate')} onClick={onWorkflowOpen} active={activeView === 'workflow'} /> diff --git a/src/renderer/src/components/chat/SidebarProjectsSection.test.ts b/src/renderer/src/components/chat/SidebarProjectsSection.test.ts index 96673ef0f..ec5c3f706 100644 --- a/src/renderer/src/components/chat/SidebarProjectsSection.test.ts +++ b/src/renderer/src/components/chat/SidebarProjectsSection.test.ts @@ -509,6 +509,7 @@ describe('ThreadRow', () => { expect(html).toContain('sidebarThreadPinned') expect(html).toContain('sidebarThreadUnpin') + expect(html).toContain('bg-[color-mix(in_srgb,var(--ds-sidebar-row-active)_72%,var(--ds-accent)_28%)]') }) }) diff --git a/src/renderer/src/components/chat/SidebarProjectsSection.tsx b/src/renderer/src/components/chat/SidebarProjectsSection.tsx index 014e4a892..aac045429 100644 --- a/src/renderer/src/components/chat/SidebarProjectsSection.tsx +++ b/src/renderer/src/components/chat/SidebarProjectsSection.tsx @@ -812,7 +812,7 @@ export function SidebarProjectsSection({ setWorkspaceContextMenu({ workspacePath, x: Math.min(event.clientX, window.innerWidth - 220), - y: Math.min(event.clientY, window.innerHeight - 170) + y: Math.min(event.clientY, window.innerHeight - 210) }) } @@ -842,6 +842,56 @@ export function SidebarProjectsSection({ }) } + const archivableWorkspaceThreads = (workspacePath: string): NormalizedThread[] => { + const targetKey = workspaceRootIdentityKey(workspacePath) + if (!targetKey) return [] + const candidateProjectPaths = sidebarWorkspaceResolutionCandidates({ + workspaceRoot, + workspaceRoots, + threadWorktrees, + threads + }) + return threads.filter((thread) => + thread.archived !== true && + workspaceRootIdentityKey( + sidebarWorkspacePathForThread(thread, threadWorktrees, candidateProjectPaths) + ) === targetKey + ) + } + + const handleArchiveWorkspaceThreads = async (workspacePath: string): Promise => { + const targets = archivableWorkspaceThreads(workspacePath) + if (targets.length === 0) return + openActionDialog({ + title: t('sidebarWorkspaceArchiveDialogTitle', { name: workspaceLabelFromPath(workspacePath) }), + description: t('sidebarWorkspaceArchiveDialogDescription', { count: targets.length }), + detail: t('sidebarWorkspaceArchiveDialogDetail'), + confirmLabel: t('sidebarWorkspaceArchiveConfirmButton'), + onConfirm: async () => { + const latestTargets = archivableWorkspaceThreads(workspacePath) + const targetIds = latestTargets.map((thread) => thread.id.trim()).filter(Boolean) + if (targetIds.length === 0) return + setDeletingThreadIds((prev) => ({ + ...prev, + ...Object.fromEntries(targetIds.map((threadId) => [threadId, true])) + })) + try { + for (const threadId of targetIds) { + await onArchiveThread(threadId) + } + } finally { + setDeletingThreadIds((prev) => { + const next = { ...prev } + for (const threadId of targetIds) { + delete next[threadId] + } + return next + }) + } + } + }) + } + const handleDeleteRequirementDraft = async (draft: SddDraftHistoryItem): Promise => { const draftId = draft.id.trim() if (!draftId || deletingDraftIds[draftId]) return @@ -1119,7 +1169,9 @@ export function SidebarProjectsSection({ onClose={() => setWorkspaceContextMenu(null)} onNewThread={() => onCreateThreadInWorkspace(workspaceContextMenu.workspacePath)} onOpenInSystem={() => void openWorkspaceInSystem(workspaceContextMenu.workspacePath)} + onArchiveThreads={() => void handleArchiveWorkspaceThreads(workspaceContextMenu.workspacePath)} onRemove={() => void handleRemoveWorkspace(workspaceContextMenu.workspacePath)} + archiveDisabled={archivableWorkspaceThreads(workspaceContextMenu.workspacePath).length === 0} t={t} /> ) : null} @@ -1592,14 +1644,18 @@ function WorkspaceContextMenu({ onClose, onNewThread, onOpenInSystem, + onArchiveThreads, onRemove, + archiveDisabled, t }: { state: WorkspaceContextMenuState onClose: () => void onNewThread: () => void onOpenInSystem: () => void + onArchiveThreads: () => void onRemove: () => void + archiveDisabled: boolean t: (k: string, opts?: Record) => string }): ReactElement { const run = (action: () => void): void => { @@ -1627,6 +1683,12 @@ function WorkspaceContextMenu({ disabled={false} onClick={() => run(onOpenInSystem)} /> + } + label={t('sidebarWorkspaceArchiveThreads')} + disabled={archiveDisabled} + onClick={() => run(onArchiveThreads)} + />
} diff --git a/src/renderer/src/components/chat/SubagentCallCard.tsx b/src/renderer/src/components/chat/SubagentCallCard.tsx index e52368b7b..455f53441 100644 --- a/src/renderer/src/components/chat/SubagentCallCard.tsx +++ b/src/renderer/src/components/chat/SubagentCallCard.tsx @@ -37,6 +37,7 @@ const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)' type DelegateDetail = { /** The child thread id — always present in the tool result, unlike `meta.child`. */ childId?: string + status?: 'queued' | 'running' | 'completed' | 'failed' | 'aborted' summary?: string error?: string profile?: string @@ -45,6 +46,7 @@ type DelegateDetail = { durationMs?: number queuedMs?: number totalTokens?: number + detached?: boolean } function parseDelegateDetail(detail: string | undefined): DelegateDetail { @@ -60,10 +62,15 @@ function parseDelegateDetail(detail: string | undefined): DelegateDetail { const usage = obj.usage && typeof obj.usage === 'object' ? (obj.usage as Record) : undefined const str = (v: unknown): string | undefined => typeof v === 'string' && v.trim() ? v.trim() : undefined + const status = (v: unknown): DelegateDetail['status'] => + v === 'queued' || v === 'running' || v === 'completed' || v === 'failed' || v === 'aborted' + ? v + : undefined const num = (v: unknown): number | undefined => typeof v === 'number' && Number.isFinite(v) ? v : undefined return { childId: str(obj.childId), + status: status(obj.status), summary: str(obj.summary), error: str(obj.error), profile: str(obj.profile), @@ -71,7 +78,8 @@ function parseDelegateDetail(detail: string | undefined): DelegateDetail { toolInvocations: num(obj.toolInvocations), durationMs: num(obj.durationMs), queuedMs: num(obj.queuedMs), - totalTokens: usage ? num(usage.totalTokens) : undefined + totalTokens: usage ? num(usage.totalTokens) : undefined, + detached: obj.detached === true } } @@ -82,6 +90,11 @@ type ChildMeta = { childStatus?: string childSeq?: number parentTurnId?: string + toolInvocations?: number + durationMs?: number + queuedMs?: number + totalTokens?: number + detached?: boolean } function readChildMeta(block: ChatBlock): ChildMeta { @@ -99,7 +112,12 @@ function readChildMeta(block: ChatBlock): ChildMeta { childProfile: str(child.childProfile), childStatus: str(child.childStatus), childSeq: typeof child.childSeq === 'number' ? child.childSeq : undefined, - parentTurnId: str(child.parentTurnId) + parentTurnId: str(child.parentTurnId), + toolInvocations: typeof child.toolInvocations === 'number' ? child.toolInvocations : undefined, + durationMs: typeof child.durationMs === 'number' ? child.durationMs : undefined, + queuedMs: typeof child.queuedMs === 'number' ? child.queuedMs : undefined, + totalTokens: typeof child.totalTokens === 'number' ? child.totalTokens : undefined, + detached: child.detached === true } } @@ -107,8 +125,17 @@ function readChildMeta(block: ChatBlock): ChildMeta { * Map the child run + block status to one of five card states. `childStatus` * (when present) wins; otherwise fall back to `block.status`. */ -function resolveStatus(block: ChatBlock, child: ChildMeta): CardStatus { +function resolveStatus(block: ChatBlock, child: ChildMeta, detail?: DelegateDetail): CardStatus { + const detached = child.detached === true || detail?.detached === true const cs = child.childStatus + if (detached) { + if (cs === 'completed') return 'done' + if (cs === 'failed' || cs === 'aborted') return 'failed' + if (cs === 'queued' || cs === 'running') return 'running' + if (detail?.status === 'completed') return 'done' + if (detail?.status === 'failed' || detail?.status === 'aborted') return 'failed' + if (detail?.status === 'queued' || detail?.status === 'running') return 'running' + } if (cs === 'queued') return 'queued' if (cs === 'running') return 'running' if (cs === 'completed') return 'done' @@ -179,7 +206,8 @@ function mmss(ms: number): string { function useElapsed( status: CardStatus, createdAt: string | undefined, - durationMs: number | undefined + durationMs: number | undefined, + tickNow?: number ): string { const start = useMemo(() => { const parsed = createdAt ? Date.parse(createdAt) : NaN @@ -194,7 +222,7 @@ function useElapsed( }, [running]) if (status === 'queued') return '—' if (isTerminal(status) && typeof durationMs === 'number') return mmss(durationMs) - return mmss(now - start) + return mmss((tickNow ?? now) - start) } const DISC_BG: Record = { @@ -264,6 +292,14 @@ function StatusPill({ status, t }: { status: CardStatus; t: (k: string) => strin } } +function BackgroundPill({ t }: { t: (k: string) => string }): ReactElement { + return ( + + {t('subagentDetachedBadge')} + + ) +} + /** 2.5px liveness lane directly under the trigger row. */ function LaneHairline({ status, animate }: { status: CardStatus; animate: boolean }): ReactElement | null { if (status === 'queued') return null @@ -359,6 +395,7 @@ export function SubagentCallCard({ block, compact = false, inGroup = false, + tickNow, onOpenChildThread }: { block: ChatBlock @@ -366,6 +403,8 @@ export function SubagentCallCard({ compact?: boolean /** Inside a SwarmHeader group: suppress own shell, inline-toggle only. */ inGroup?: boolean + /** Parent group clock used to keep all child timers moving in lockstep. */ + tickNow?: number onOpenChildThread?: OpenChildThreadHandler }): ReactElement | null { const { t } = useTranslation('common') @@ -379,7 +418,8 @@ export function SubagentCallCard({ () => parseDelegateDetail(block.kind === 'tool' ? (block as ToolBlock).detail : undefined), [block] ) - const status = resolveStatus(block, child) + const status = resolveStatus(block, child, detail) + const detached = child.detached === true || detail.detached === true const animate = !reducedMotion && onScreen && status === 'running' // Profile id: prefer the live `childProfile` from the runtime metadata (set on @@ -406,8 +446,8 @@ export function SubagentCallCard({ .filter((p): p is string => Boolean(p && p.trim())) const taskLine = taskParts.join(' · ') - const elapsed = useElapsed(status, block.createdAt, detail.durationMs) - const steps = detail.toolInvocations + const elapsed = useElapsed(status, block.createdAt, child.durationMs ?? detail.durationMs, tickNow) + const steps = child.toolInvocations ?? detail.toolInvocations // Always start collapsed — both while running and after it finishes. The card // only opens when the user clicks it (no auto-expand on terminal transition). @@ -465,6 +505,7 @@ export function SubagentCallCard({ {roleName} + {detached ? : null} {!compact || !inGroup ? : null} {taskLine ? ( @@ -476,9 +517,9 @@ export function SubagentCallCard({ {typeof steps === 'number' ? t('subagentSteps', { count: steps }) - : status === 'queued' && typeof detail.queuedMs === 'number' - ? t('subagentQueuedHint') - : ''} + : status === 'queued' && typeof (child.queuedMs ?? detail.queuedMs) === 'number' + ? t('subagentQueuedHint') + : ''} {childId ? ( @@ -520,8 +561,8 @@ export function SubagentCallCard({
{detail.profile ? {detail.profile} : null} - {typeof detail.totalTokens === 'number' && detail.totalTokens > 0 ? ( - {t('subagentTokensChip', { count: detail.totalTokens })} + {typeof (child.totalTokens ?? detail.totalTokens) === 'number' && (child.totalTokens ?? detail.totalTokens ?? 0) > 0 ? ( + {t('subagentTokensChip', { count: child.totalTokens ?? detail.totalTokens })} ) : null} {detail.toolPolicy ? ( @@ -578,12 +619,7 @@ export function SubagentGroup({ const { t } = useTranslation('common') const [collapsed, setCollapsed] = useState(false) const reducedMotion = useReducedMotion() - - if (blocks.length === 0) return null - // N=1: single full card, no swarm header. - if (blocks.length === 1) { - return - } + const [tickNow, setTickNow] = useState(() => Date.now()) const sorted = [...blocks].sort((a, b) => { const sa = readChildMeta(a).childSeq ?? 0 @@ -595,12 +631,26 @@ export function SubagentGroup({ let queued = 0 let done = 0 for (const b of sorted) { - const s = resolveStatus(b, readChildMeta(b)) + const detail = parseDelegateDetail(b.kind === 'tool' ? (b as ToolBlock).detail : undefined) + const s = resolveStatus(b, readChildMeta(b), detail) if (s === 'running' || s === 'awaiting-permission') running += 1 else if (s === 'queued') queued += 1 else if (s === 'done') done += 1 } const anyRunning = running > 0 || queued > 0 + useEffect(() => { + if (!anyRunning) return + setTickNow(Date.now()) + const id = window.setInterval(() => setTickNow(Date.now()), 1000) + return () => window.clearInterval(id) + }, [anyRunning]) + + if (sorted.length === 0) return null + + // N=1: single full card, no swarm header. + if (sorted.length === 1) { + return + } const clusterPoses = sorted.slice(0, 5).map((b) => { const c = readChildMeta(b) @@ -671,6 +721,7 @@ export function SubagentGroup({ block={b} compact inGroup + tickNow={tickNow} onOpenChildThread={onOpenChildThread} /> ))} diff --git a/src/renderer/src/components/chat/WorkbenchTopBar.test.ts b/src/renderer/src/components/chat/WorkbenchTopBar.test.ts new file mode 100644 index 000000000..9ed2f5377 --- /dev/null +++ b/src/renderer/src/components/chat/WorkbenchTopBar.test.ts @@ -0,0 +1,50 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import i18n from '../../i18n' +import { WorkbenchSideRail } from './WorkbenchTopBar' + +describe('WorkbenchSideRail', () => { + beforeEach(async () => { + await i18n.changeLanguage('en') + }) + + it('renders visible tooltip labels for right rail icon buttons', () => { + const html = renderToStaticMarkup( + createElement(WorkbenchSideRail, { + rightPanelMode: null, + onToggleRightPanelMode: vi.fn(), + planPanelEnabled: true, + canvasEnabled: true, + terminalOpen: false, + onToggleTerminal: vi.fn(), + sideChatCount: 0, + sideChatRunningCount: 0, + sideChatOpen: false, + sideChatEnabled: true, + fileTreeOpen: false, + fileTreeEnabled: true, + onToggleFileTree: vi.fn(), + onOpenSideChat: vi.fn() + }) + ) + + for (const label of [ + 'Choose default editor', + 'Open branch conversation', + 'Todo', + 'Plan', + 'Changes', + 'Terminal', + 'Preview', + 'Whiteboard', + 'Subagents', + 'Files' + ]) { + expect(html).toContain(`data-tooltip="${label}"`) + expect(html).toContain(`title="${label}"`) + } + + expect(html.match(/ds-side-rail-button/g)?.length).toBeGreaterThanOrEqual(10) + }) +}) diff --git a/src/renderer/src/components/chat/WorkbenchTopBar.tsx b/src/renderer/src/components/chat/WorkbenchTopBar.tsx index d3625032d..595da8a37 100644 --- a/src/renderer/src/components/chat/WorkbenchTopBar.tsx +++ b/src/renderer/src/components/chat/WorkbenchTopBar.tsx @@ -53,6 +53,15 @@ type Props = { } const TOPBAR_ICON_CLASS = 'h-4 w-4' +const SIDE_RAIL_BUTTON_BASE = + 'ds-side-rail-button inline-flex h-8 w-8 items-center justify-center rounded-[0.9rem] border shadow-[inset_0_1px_0_rgba(255,255,255,0.45)] transition dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]' +const SIDE_RAIL_BUTTON_ACTIVE = 'border-ds-border-strong bg-white/70 text-ds-ink dark:bg-white/10' +const SIDE_RAIL_BUTTON_IDLE = + 'border-transparent bg-white/38 text-ds-faint opacity-90 hover:border-ds-border-muted hover:bg-white/55 hover:text-ds-ink hover:opacity-100 dark:bg-white/4 dark:hover:bg-white/8' + +function sideRailButtonClass(active: boolean, extra?: string): string { + return `${SIDE_RAIL_BUTTON_BASE} ${active ? SIDE_RAIL_BUTTON_ACTIVE : SIDE_RAIL_BUTTON_IDLE}${extra ? ` ${extra}` : ''}` +} export function WorkbenchSideRail({ rightPanelMode, @@ -90,6 +99,9 @@ export function WorkbenchSideRail({ () => editors.find((editor) => editor.id === selectedEditorId) ?? editors[0], [editors, selectedEditorId] ) + const editorButtonTitle = selectedEditor + ? t('editorPickerTitleWithEditor', { editor: selectedEditor.label }) + : t('editorPickerTitle') useEffect(() => { let cancelled = false @@ -280,7 +292,8 @@ export function WorkbenchSideRail({ type="button" onClick={() => void runGuiUpdateAction()} disabled={guiUpdateBusy} - className="relative inline-flex h-8 w-8 items-center justify-center rounded-[0.9rem] border border-amber-300/75 bg-amber-50/92 text-amber-950 shadow-[inset_0_1px_0_rgba(255,255,255,0.55)] transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-amber-700/70 dark:bg-amber-950/35 dark:text-amber-100 dark:hover:bg-amber-900/45" + className="ds-side-rail-button relative inline-flex h-8 w-8 items-center justify-center rounded-[0.9rem] border border-amber-300/75 bg-amber-50/92 text-amber-950 shadow-[inset_0_1px_0_rgba(255,255,255,0.55)] transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-amber-700/70 dark:bg-amber-950/35 dark:text-amber-100 dark:hover:bg-amber-900/45" + data-tooltip={guiUpdateBusy ? guiUpdateLabel : guiUpdateTitle} aria-label={guiUpdateBusy ? guiUpdateLabel : guiUpdateTitle} title={guiUpdateBusy ? guiUpdateLabel : guiUpdateTitle} > @@ -295,14 +308,11 @@ export function WorkbenchSideRail({ @@ -345,11 +355,8 @@ export function WorkbenchSideRail({ type="button" onClick={onOpenSideChat} disabled={!sideChatEnabled} - className={`relative inline-flex h-8 w-8 items-center justify-center rounded-[0.9rem] border shadow-[inset_0_1px_0_rgba(255,255,255,0.45)] transition disabled:cursor-not-allowed disabled:opacity-45 dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] ${ - sideChatOpen - ? 'border-ds-border-strong bg-white/70 text-ds-ink dark:bg-white/10' - : 'border-transparent bg-white/38 text-ds-faint opacity-90 hover:border-ds-border-muted hover:bg-white/55 hover:text-ds-ink hover:opacity-100 dark:bg-white/4 dark:hover:bg-white/8' - }`} + className={sideRailButtonClass(sideChatOpen, 'relative disabled:cursor-not-allowed disabled:opacity-45')} + data-tooltip={t('sidePanelOpen')} aria-label={t('sidePanelOpen')} aria-pressed={sideChatOpen} title={t('sidePanelOpen')} @@ -375,11 +382,8 @@ export function WorkbenchSideRail({
) diff --git a/src/renderer/src/components/chat/__tests__/WorkspaceModeTabs.test.ts b/src/renderer/src/components/chat/__tests__/WorkspaceModeTabs.test.ts index ab763d7f8..265ab8d02 100644 --- a/src/renderer/src/components/chat/__tests__/WorkspaceModeTabs.test.ts +++ b/src/renderer/src/components/chat/__tests__/WorkspaceModeTabs.test.ts @@ -9,7 +9,7 @@ describe('WorkspaceModeTabs', () => { await i18n.changeLanguage('en') }) - function props(activeView: 'chat' | 'write' | 'design' = 'chat') { + function props(activeView: 'chat' | 'workflow' | 'write' | 'design' = 'chat') { return { activeView, onCodeOpen: vi.fn(), @@ -18,12 +18,13 @@ describe('WorkspaceModeTabs', () => { } } - it('renders three tab buttons', () => { + it('renders three top-level mode tab buttons', () => { const html = renderToStaticMarkup(createElement(WorkspaceModeTabs, props())) expect(html).toContain('Code') expect(html).toContain('Write') expect(html).toContain('Design') + expect(html).not.toContain('Loop') expect(html.match(/role="tab"/g)?.length).toBe(3) }) @@ -54,21 +55,27 @@ describe('WorkspaceModeTabs', () => { } }) - it('preserves truncate class on button text for narrow sidebars', () => { + it('does not mark a top tab active while the moved Loop view is active', () => { + const html = renderToStaticMarkup(createElement(WorkspaceModeTabs, props('workflow'))) + + expect(html).not.toContain('aria-selected="true"') + expect(html.match(/aria-selected="false"/g)?.length).toBe(3) + }) + + it('uses all-or-icon labels instead of truncating tab text', () => { const html = renderToStaticMarkup( createElement(WorkspaceModeTabs, props()) ) - const truncateMatches = html.match(/truncate/g) - expect(truncateMatches?.length).toBe(3) + expect(html).toContain('workspace-mode-tab-label') + expect(html).not.toContain('truncate') }) - it('preserves min-w-0 on buttons for flex truncation', () => { + it('preserves min-w-0 on buttons for flex sizing', () => { const html = renderToStaticMarkup( createElement(WorkspaceModeTabs, props()) ) - // min-w-0 must be present to allow truncate to work in flex children expect(html).toContain('min-w-0') }) diff --git a/src/renderer/src/components/chat/composer-model-selection.test.ts b/src/renderer/src/components/chat/composer-model-selection.test.ts index dee2c9a3f..f07f37125 100644 --- a/src/renderer/src/components/chat/composer-model-selection.test.ts +++ b/src/renderer/src/components/chat/composer-model-selection.test.ts @@ -5,12 +5,11 @@ import { } from './composer-model-selection' describe('composer model selection helpers', () => { - it('builds assistant pick lists from defaults, available models, and the current model', () => { + it('builds assistant pick lists from defaults and available models only', () => { expect(buildComposerAssistantPickList({ defaultModelIds: ['auto', 'deepseek-chat'], - composerPickList: ['claude-sonnet', ' auto ', 'deepseek-chat'], - currentModel: ' custom-model ' - })).toEqual(['deepseek-chat', 'claude-sonnet', 'custom-model']) + composerPickList: ['claude-sonnet', ' auto ', 'deepseek-chat'] + })).toEqual(['deepseek-chat', 'claude-sonnet']) }) it('keeps a stored provider when it still owns the selected model', () => { diff --git a/src/renderer/src/components/chat/composer-model-selection.ts b/src/renderer/src/components/chat/composer-model-selection.ts index ad5b3e7d7..db8bc960a 100644 --- a/src/renderer/src/components/chat/composer-model-selection.ts +++ b/src/renderer/src/components/chat/composer-model-selection.ts @@ -13,7 +13,6 @@ function addSelectableModel(ids: Set, modelId: string): void { export function buildComposerAssistantPickList(input: { composerPickList: readonly string[] - currentModel: string defaultModelIds?: readonly string[] }): string[] { const ordered = new Set() @@ -23,7 +22,6 @@ export function buildComposerAssistantPickList(input: { for (const id of input.composerPickList) { addSelectableModel(ordered, id) } - addSelectableModel(ordered, input.currentModel) return [...ordered] } diff --git a/src/renderer/src/components/chat/message-timeline-bubbles.tsx b/src/renderer/src/components/chat/message-timeline-bubbles.tsx index b9a6d33c9..af6a6b652 100644 --- a/src/renderer/src/components/chat/message-timeline-bubbles.tsx +++ b/src/renderer/src/components/chat/message-timeline-bubbles.tsx @@ -9,8 +9,9 @@ import { getProvider } from '../../agent/registry' import { parseWritePromptForDisplay } from '../../write/quoted-selection' import { parseClawUserPromptForDisplay, type ClawUserPromptDisplay } from '@shared/app-settings' import { parseBackgroundShellCompletionNotice } from '@shared/background-shell-notice' +import { parseBackgroundSubagentCompletionNotice } from '@shared/background-subagent-notice' import type { WriteExportFormat } from '@shared/write-export' -import { isBackgroundShellNoticeBlock } from './message-timeline-turns' +import { isBackgroundShellNoticeBlock, isBackgroundSubagentNoticeBlock } from './message-timeline-turns' import { openWorkspacePathInEditor } from '../../lib/open-workspace-path' import { DiffView } from '../DiffView' import { AssistantMarkdown } from './AssistantMarkdown' @@ -125,6 +126,83 @@ function BackgroundShellNoticeBubble({ ) } +function BackgroundSubagentNoticeBubble({ + block, + nested = false +}: { + block: Extract + nested?: boolean +}): ReactElement { + const { t } = useTranslation('common') + const parsed = useMemo(() => parseBackgroundSubagentCompletionNotice(block.text), [block.text]) + const title = + block.meta?.displayText?.trim() || + t('backgroundSubagentNotice.title', { defaultValue: 'Background subagent completed' }) + const isFailed = parsed?.status === 'failed' + const statusTone = isFailed + ? 'border-orange-400/30 bg-orange-500/10 text-orange-800 dark:text-orange-200' + : 'border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' + + return ( +
+
+
+ + {t('backgroundSubagentNotice.kindLabel', { defaultValue: 'Background callback' })} + + {parsed ? ( + <> + + + {t('backgroundSubagentNotice.childId', { defaultValue: 'Child' })} + + {parsed.childId} + + + + {t('backgroundSubagentNotice.status', { defaultValue: 'Status' })} + + {parsed.status} + + + ) : null} +
+
+

{title}

+ {parsed?.label ? ( +
+
+
+ {t('backgroundSubagentNotice.agent', { defaultValue: 'Agent' })} +
+
{parsed.label}
+
+
+ ) : null} + {parsed?.summary ? ( +
+

+ {t('backgroundSubagentNotice.summary', { defaultValue: 'Summary' })} +

+
+ +
+
+ ) : null} + {parsed?.error ? ( +
+              {parsed.error}
+            
+ ) : null} +
+
+
+ ) +} + /** * User message bubble with hover affordance to rewind/edit. Click the rewind * pill, the bubble flips into a textarea, and Resend submits an edited @@ -1470,6 +1548,9 @@ function MessageBubbleImpl({ if (block.kind === 'user' && isBackgroundShellNoticeBlock(block)) { return } + if (block.kind === 'user' && isBackgroundSubagentNoticeBlock(block)) { + return + } if (block.kind === 'user') { return } diff --git a/src/renderer/src/components/chat/message-timeline-process.tsx b/src/renderer/src/components/chat/message-timeline-process.tsx index d910539c9..5609670ae 100644 --- a/src/renderer/src/components/chat/message-timeline-process.tsx +++ b/src/renderer/src/components/chat/message-timeline-process.tsx @@ -25,7 +25,12 @@ import { previewWorkspaceFile } from '../../lib/workspace-file-preview' import { DiffView } from '../DiffView' import { AssistantMarkdown } from './AssistantMarkdown' import { MessageBubble } from './message-timeline-bubbles' -import { blockHasPendingRuntimeWork, isBackgroundShellNoticeBlock, splitThink } from './message-timeline-turns' +import { + blockHasPendingRuntimeWork, + isBackgroundShellNoticeBlock, + isBackgroundSubagentNoticeBlock, + splitThink +} from './message-timeline-turns' import { formatDuration, formatToolTitle, @@ -729,6 +734,7 @@ function processBlockIcon(block: ChatBlock): LucideIcon | null { if (block.kind === 'approval') return Wrench if (block.kind === 'user_input') return MessageSquareQuote if (isBackgroundShellNoticeBlock(block)) return BellRing + if (isBackgroundSubagentNoticeBlock(block)) return BellRing if (block.kind !== 'tool') return null return toolBlockIcon(block) } @@ -878,6 +884,7 @@ type ProcessDetail = | { kind: 'approval' } | { kind: 'user_input' } | { kind: 'background_shell' } + | { kind: 'background_subagent' } | { kind: 'text'; text: string } function summarizeProcessText(text: string, max = 96): string { @@ -1161,6 +1168,7 @@ function getProcessDetail(block: ChatBlock, summaryText?: string): ProcessDetail if (block.kind === 'approval') return { kind: 'approval' } if (block.kind === 'user_input') return { kind: 'user_input' } if (isBackgroundShellNoticeBlock(block)) return { kind: 'background_shell' } + if (isBackgroundSubagentNoticeBlock(block)) return { kind: 'background_subagent' } if (block.kind === 'system' && block.text.trim()) { if (block.detail?.trim()) return { kind: 'text', text: block.detail } // Short system messages already fit in the summary line — skip the @@ -1231,7 +1239,7 @@ function ProcessEntryDetail({ if (detail.kind === 'user_input' && block.kind === 'user_input') { return } - if (detail.kind === 'background_shell' && block.kind === 'user') { + if ((detail.kind === 'background_shell' || detail.kind === 'background_subagent') && block.kind === 'user') { return } return null @@ -1253,6 +1261,9 @@ function describeProcessBlock( if (block.kind === 'user' && isBackgroundShellNoticeBlock(block)) { return block.meta?.displayText?.trim() || t('backgroundShellNotice.title', { defaultValue: 'Background shell completed' }) } + if (block.kind === 'user' && isBackgroundSubagentNoticeBlock(block)) { + return block.meta?.displayText?.trim() || t('backgroundSubagentNotice.title', { defaultValue: 'Background subagent completed' }) + } if (block.kind === 'compaction') { if (block.status === 'running') return t('compactionRunning') if (block.status === 'error') return block.summary || t('compactionFailed') diff --git a/src/renderer/src/components/chat/message-timeline-turns.test.ts b/src/renderer/src/components/chat/message-timeline-turns.test.ts index 0d1ba8eda..dbb55ed84 100644 --- a/src/renderer/src/components/chat/message-timeline-turns.test.ts +++ b/src/renderer/src/components/chat/message-timeline-turns.test.ts @@ -68,4 +68,45 @@ describe('message timeline turns', () => { expect(turns[0]?.blocks).toHaveLength(1) expect(turns[0]?.blocks[0]?.id).toBe('notice_2') }) + + it('keeps background subagent notices inside the current turn', () => { + const notice: ChatBlock = { + kind: 'user', + id: 'notice_subagent_1', + text: 'child-1completeddone', + meta: { + displayText: 'Background subagent 后台休眠 completed', + messageSource: 'background_subagent' + } + } + const blocks: ChatBlock[] = [ + { kind: 'user', id: 'user_1', text: 'Run one background subagent' }, + { kind: 'assistant', id: 'assistant_1', text: 'Started.' }, + notice, + { kind: 'assistant', id: 'assistant_2', text: 'Background finished.' } + ] + + const turns = groupTurns(blocks) + + expect(turns).toHaveLength(1) + expect(turns[0]?.user?.id).toBe('user_1') + expect(turns[0]?.blocks.map((block) => block.id)).toEqual([ + 'assistant_1', + 'notice_subagent_1', + 'assistant_2' + ]) + }) + + it('does not treat ordinary user prompts as background subagent notices', () => { + const blocks: ChatBlock[] = [ + { kind: 'user', id: 'user_1', text: 'Run one background subagent' }, + { kind: 'assistant', id: 'assistant_1', text: 'Started.' }, + { kind: 'user', id: 'user_2', text: 'Background subagent 后台休眠 completed' } + ] + + const turns = groupTurns(blocks) + + expect(turns).toHaveLength(2) + expect(turns[1]?.user?.id).toBe('user_2') + }) }) diff --git a/src/renderer/src/components/chat/message-timeline-turns.ts b/src/renderer/src/components/chat/message-timeline-turns.ts index a4ce592bc..424e54ba8 100644 --- a/src/renderer/src/components/chat/message-timeline-turns.ts +++ b/src/renderer/src/components/chat/message-timeline-turns.ts @@ -1,5 +1,7 @@ import type { ChatBlock } from '../../agent/types' import { isBackgroundShellNoticeUserMessage } from '@shared/background-shell-notice' +import { isBackgroundSubagentNoticeUserMessage } from '@shared/background-subagent-notice' +import { hasPendingRuntimeWork } from '../../store/chat-store-runtime-helpers' export type Turn = { user?: Extract @@ -10,13 +12,21 @@ export function isBackgroundShellNoticeBlock(block: ChatBlock): boolean { return block.kind === 'user' && isBackgroundShellNoticeUserMessage(block) } +export function isBackgroundSubagentNoticeBlock(block: ChatBlock): boolean { + return block.kind === 'user' && isBackgroundSubagentNoticeUserMessage(block) +} + +export function isBackgroundNoticeBlock(block: ChatBlock): boolean { + return isBackgroundShellNoticeBlock(block) || isBackgroundSubagentNoticeBlock(block) +} + export function groupTurns(blocks: ChatBlock[]): Turn[] { const turns: Turn[] = [] let current: Turn | null = null for (const block of blocks) { if (block.kind === 'user') { - if (isBackgroundShellNoticeBlock(block)) { + if (isBackgroundNoticeBlock(block)) { if (!current) current = { blocks: [] } current.blocks.push(block) continue @@ -56,17 +66,12 @@ export function splitThink(text: string): { think: string; content: string } { } export function blockHasPendingRuntimeWork(block: ChatBlock): boolean { - if (block.kind === 'tool') return block.status === 'running' - if (block.kind === 'compaction') return block.status === 'running' - if (block.kind === 'review') return block.status === 'running' - if (block.kind === 'approval') return block.status === 'pending' - if (block.kind === 'user_input') return block.status === 'pending' - return false + return hasPendingRuntimeWork(block) } export function isProcessBlock(block: ChatBlock): boolean { return ( - isBackgroundShellNoticeBlock(block) || + isBackgroundNoticeBlock(block) || block.kind === 'reasoning' || block.kind === 'tool' || block.kind === 'compaction' || diff --git a/src/renderer/src/components/chat/use-voice-dictation.ts b/src/renderer/src/components/chat/use-voice-dictation.ts index c597f74c7..e5a7429a4 100644 --- a/src/renderer/src/components/chat/use-voice-dictation.ts +++ b/src/renderer/src/components/chat/use-voice-dictation.ts @@ -2,7 +2,9 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { resolveKunSpeechToTextSettings, + getKunRuntimeSettings, type AppSettingsV1, + type KunPromptOptimizationSettingsV1, type KunSpeechToTextSettingsV1 } from '@shared/app-settings' import { SPEECH_TRANSCRIPTION_MAX_DURATION_MS } from '@shared/speech-to-text' @@ -42,6 +44,30 @@ export function useSpeechToTextSettings(): KunSpeechToTextSettingsV1 | null { return speechToText } +export function usePromptOptimizationSettings(): KunPromptOptimizationSettingsV1 | null { + const [promptOptimization, setPromptOptimization] = useState(null) + + useEffect(() => { + let cancelled = false + const apply = (settings: AppSettingsV1): void => { + if (!cancelled) setPromptOptimization(getKunRuntimeSettings(settings).promptOptimization) + } + if (typeof window.kunGui?.getSettings === 'function') { + void window.kunGui.getSettings().then(apply).catch(() => undefined) + } + const onSettingsChanged = (event: Event): void => { + apply((event as CustomEvent).detail) + } + window.addEventListener(SETTINGS_CHANGED_EVENT, onSettingsChanged) + return () => { + cancelled = true + window.removeEventListener(SETTINGS_CHANGED_EVENT, onSettingsChanged) + } + }, []) + + return promptOptimization +} + export function useVoiceDictation({ onText, speechToText diff --git a/src/renderer/src/components/design/canvas/ImageAnnotationEditor.test.ts b/src/renderer/src/components/design/canvas/ImageAnnotationEditor.test.ts index 6fcb406c6..dd9b0a547 100644 --- a/src/renderer/src/components/design/canvas/ImageAnnotationEditor.test.ts +++ b/src/renderer/src/components/design/canvas/ImageAnnotationEditor.test.ts @@ -1,7 +1,23 @@ import { createElement } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it } from 'vitest' -import { ImageAnnotationEditor } from './ImageAnnotationEditor' +import { + createImageAnnotationTextDraftAtCssPoint, + createImageAnnotationTextOp, + ImageAnnotationEditor, + imageAnnotationTextNotes, + shouldCommitImageAnnotationTextKey +} from './ImageAnnotationEditor' + +const draft = { + cssX: 10, + cssY: 12, + x: 100, + y: 120, + cssFontSize: 24, + cssLineHeight: 28.8, + maxCssWidth: 300 +} function renderEditor(): string { return renderToStaticMarkup( @@ -34,3 +50,126 @@ describe('ImageAnnotationEditor layout', () => { expect(html).not.toContain('bg-white/12') }) }) + +describe('ImageAnnotationEditor text annotations', () => { + it('creates trimmed pending text annotations', () => { + expect( + createImageAnnotationTextOp( + draft, + ' 改成蓝色 ', + '#3b82f6', + 36 + ) + ).toEqual({ + kind: 'text', + color: '#3b82f6', + x: 100, + y: 120, + text: '改成蓝色', + fontSize: 36 + }) + + expect(createImageAnnotationTextOp(null, '改成蓝色', '#3b82f6', 36)).toBeNull() + expect(createImageAnnotationTextOp({ ...draft, cssX: 0, cssY: 0, x: 0, y: 0 }, ' ', '#3b82f6', 36)).toBeNull() + }) + + it('extracts text notes from committed and pending operations', () => { + const textOp = createImageAnnotationTextOp( + draft, + '标题放大', + '#111827', + 24 + ) + + if (!textOp) throw new Error('expected a text annotation op') + + expect( + imageAnnotationTextNotes([ + { kind: 'arrow', color: '#ef4444', width: 4, from: { x: 0, y: 0 }, to: { x: 20, y: 20 } }, + textOp + ]) + ).toEqual(['标题放大']) + }) + + it('does not commit Enter while an IME composition is active', () => { + expect(shouldCommitImageAnnotationTextKey('Enter', false, false)).toBe(false) + expect(shouldCommitImageAnnotationTextKey('Enter', false, false, true)).toBe(true) + expect(shouldCommitImageAnnotationTextKey('Escape', false, false)).toBe(true) + expect(shouldCommitImageAnnotationTextKey('Enter', true, false, true)).toBe(false) + expect(shouldCommitImageAnnotationTextKey('Enter', false, true, true)).toBe(false) + expect(shouldCommitImageAnnotationTextKey('a', false, false)).toBe(false) + }) + + it('creates a text draft at the clicked canvas point', () => { + expect( + createImageAnnotationTextDraftAtCssPoint({ + canvasWidth: 1000, + canvasHeight: 800, + cssWidth: 500, + cssHeight: 400, + cssX: 60, + cssY: 48, + canvasFontSize: 60 + }) + ).toEqual({ + cssX: 60, + cssY: 48, + x: 120, + y: 96, + cssFontSize: 30, + cssLineHeight: 36, + maxCssWidth: 432 + }) + + expect( + createImageAnnotationTextDraftAtCssPoint({ + canvasWidth: 0, + canvasHeight: 800, + cssWidth: 500, + cssHeight: 400, + cssX: 60, + cssY: 48, + canvasFontSize: 60 + }) + ).toBeNull() + }) + + it('keeps the text anchor at the clicked point near canvas edges', () => { + expect( + createImageAnnotationTextDraftAtCssPoint({ + canvasWidth: 1000, + canvasHeight: 800, + cssWidth: 500, + cssHeight: 400, + cssX: 490, + cssY: 390, + canvasFontSize: 60 + }) + ).toMatchObject({ + cssX: 490, + cssY: 390, + x: 980, + y: 780, + maxCssWidth: 120 + }) + }) + + it('only clamps text anchors that fall outside the canvas', () => { + expect( + createImageAnnotationTextDraftAtCssPoint({ + canvasWidth: 1000, + canvasHeight: 800, + cssWidth: 500, + cssHeight: 400, + cssX: -20, + cssY: 420, + canvasFontSize: 60 + }) + ).toMatchObject({ + cssX: 0, + cssY: 400, + x: 0, + y: 800 + }) + }) +}) diff --git a/src/renderer/src/components/design/canvas/ImageAnnotationEditor.tsx b/src/renderer/src/components/design/canvas/ImageAnnotationEditor.tsx index 02fa9eff6..b2685c12a 100644 --- a/src/renderer/src/components/design/canvas/ImageAnnotationEditor.tsx +++ b/src/renderer/src/components/design/canvas/ImageAnnotationEditor.tsx @@ -21,6 +21,16 @@ type AnnotationOp = | { kind: 'rect'; color: string; width: number; from: Point; to: Point } | { kind: 'text'; color: string; x: number; y: number; text: string; fontSize: number } +export type ImageAnnotationTextDraft = { + cssX: number + cssY: number + x: number + y: number + cssFontSize: number + cssLineHeight: number + maxCssWidth: number +} + export type ImageAnnotationResult = { /** Base64 PNG bytes of the flattened picture + markup (no `data:` prefix). */ dataBase64: string @@ -50,6 +60,9 @@ const SWATCHES: { name: string; value: string }[] = [ ] const MAX_FLATTEN_DIM = 1280 +const TEXT_EDITOR_MIN_WIDTH = 120 +const TEXT_EDITOR_MARGIN = 8 +const TEXT_LINE_HEIGHT = 1.2 const TOOLS: { tool: AnnotationTool; label: string; Icon: typeof Pencil }[] = [ { tool: 'pen', label: '画笔', Icon: Pencil }, @@ -58,6 +71,93 @@ const TOOLS: { tool: AnnotationTool; label: string; Icon: typeof Pencil }[] = [ { tool: 'text', label: '文字', Icon: Type } ] +export function createImageAnnotationTextOp( + draft: ImageAnnotationTextDraft | null, + rawValue: string, + color: string, + fontSize: number +): Extract | null { + const text = rawValue.trim() + if (!draft || !text) return null + return { kind: 'text', color, x: draft.x, y: draft.y, text, fontSize } +} + +export function imageAnnotationTextNotes(ops: readonly AnnotationOp[]): string[] { + return ops + .filter((op): op is Extract => op.kind === 'text') + .map((op) => op.text) +} + +export function shouldCommitImageAnnotationTextKey( + key: string, + nativeIsComposing: boolean, + activeComposition: boolean, + ctrlOrMetaKey = false +): boolean { + if (nativeIsComposing || activeComposition) return false + return key === 'Escape' || (key === 'Enter' && ctrlOrMetaKey) +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max) +} + +export function createImageAnnotationTextDraftAtCssPoint(input: { + canvasWidth: number + canvasHeight: number + cssWidth: number + cssHeight: number + cssX: number + cssY: number + canvasFontSize: number +}): ImageAnnotationTextDraft | null { + if (input.canvasWidth <= 0 || input.canvasHeight <= 0 || input.cssWidth <= 0 || input.cssHeight <= 0) { + return null + } + const cssX = clamp(input.cssX, 0, input.cssWidth) + const cssY = clamp(input.cssY, 0, input.cssHeight) + const sx = input.canvasWidth / input.cssWidth + const sy = input.canvasHeight / input.cssHeight + const cssFontSize = Math.max(16, input.canvasFontSize / Math.max(sx, sy)) + return { + cssX, + cssY, + x: cssX * sx, + y: cssY * sy, + cssFontSize, + cssLineHeight: cssFontSize * TEXT_LINE_HEIGHT, + maxCssWidth: Math.max(TEXT_EDITOR_MIN_WIDTH, input.cssWidth - cssX - TEXT_EDITOR_MARGIN) + } +} + +function resizeTextEditor(textarea: HTMLTextAreaElement, draft: ImageAnnotationTextDraft): void { + textarea.style.width = `${TEXT_EDITOR_MIN_WIDTH}px` + textarea.style.height = `${draft.cssLineHeight}px` + const nextWidth = clamp( + Math.ceil(textarea.scrollWidth) + 2, + Math.min(TEXT_EDITOR_MIN_WIDTH, draft.maxCssWidth), + draft.maxCssWidth + ) + textarea.style.width = `${nextWidth}px` + textarea.style.height = `${Math.max(draft.cssLineHeight, textarea.scrollHeight)}px` +} + +function paintTextOp(ctx: CanvasRenderingContext2D, op: Extract): void { + ctx.font = `600 ${op.fontSize}px Inter, system-ui, sans-serif` + ctx.textBaseline = 'top' + ctx.lineWidth = Math.max(2, op.fontSize / 8) + ctx.strokeStyle = op.color === '#ffffff' ? 'rgba(0,0,0,0.55)' : 'rgba(255,255,255,0.85)' + ctx.fillStyle = op.color + const lineHeight = op.fontSize * TEXT_LINE_HEIGHT + const lines = op.text.split(/\r?\n/) + for (let index = 0; index < lines.length; index++) { + const line = lines[index] + const y = op.y + index * lineHeight + ctx.strokeText(line, op.x, y) + ctx.fillText(line, op.x, y) + } +} + export const IMAGE_ANNOTATION_ROOT_CLASS = 'ds-no-drag fixed inset-0 z-[200] flex flex-col bg-black/75 backdrop-blur-sm' @@ -123,15 +223,7 @@ function paintOp(ctx: CanvasRenderingContext2D, op: AnnotationOp): void { ) return } - // text - ctx.font = `600 ${op.fontSize}px Inter, system-ui, sans-serif` - ctx.textBaseline = 'top' - // Halo for legibility on busy images. - ctx.lineWidth = Math.max(2, op.fontSize / 8) - ctx.strokeStyle = op.color === '#ffffff' ? 'rgba(0,0,0,0.55)' : 'rgba(255,255,255,0.85)' - ctx.strokeText(op.text, op.x, op.y) - ctx.fillStyle = op.color - ctx.fillText(op.text, op.x, op.y) + paintTextOp(ctx, op) } export function ImageAnnotationEditor({ @@ -152,10 +244,12 @@ export function ImageAnnotationEditor({ /** Longest natural edge of the loaded picture; drives stroke/font scaling. */ const [naturalLongest, setNaturalLongest] = useState(0) const [instruction, setInstruction] = useState('') - const [textDraft, setTextDraft] = useState<{ cssX: number; cssY: number; x: number; y: number } | null>( - null - ) + const [textDraft, setTextDraft] = useState(null) const [textValue, setTextValue] = useState('') + const textInputRef = useRef(null) + const textDraftRef = useRef(null) + const textValueRef = useRef('') + const textCompositionRef = useRef(false) // Live drag state for the in-progress shape (rubber-band preview). const dragRef = useRef<{ op: AnnotationOp } | null>(null) @@ -219,6 +313,36 @@ export function ImageAnnotationEditor({ if (dragRef.current) paintOp(ctx, dragRef.current.op) }) + useEffect(() => { + if (!textDraft) return undefined + let cancelled = false + const focusInput = (): void => { + if (cancelled) return + const textarea = textInputRef.current + if (!textarea) return + resizeTextEditor(textarea, textDraft) + textarea.focus({ preventScroll: true }) + } + if (typeof window.requestAnimationFrame === 'function') { + const frame = window.requestAnimationFrame(focusInput) + return () => { + cancelled = true + window.cancelAnimationFrame(frame) + } + } + const timer = window.setTimeout(focusInput, 0) + return () => { + cancelled = true + window.clearTimeout(timer) + } + }, [textDraft]) + + useEffect(() => { + const textarea = textInputRef.current + if (!textarea || !textDraft) return + resizeTextEditor(textarea, textDraft) + }, [textDraft, textValue]) + const toCanvasPoint = useCallback((e: React.PointerEvent): Point => { const canvas = canvasRef.current if (!canvas) return { x: 0, y: 0 } @@ -228,14 +352,58 @@ export function ImageAnnotationEditor({ return { x: (e.clientX - rect.left) * sx, y: (e.clientY - rect.top) * sy } }, []) + const openTextDraft = useCallback((draft: ImageAnnotationTextDraft) => { + textCompositionRef.current = false + textDraftRef.current = draft + textValueRef.current = '' + setTextValue('') + setTextDraft(draft) + }, []) + + const cancelTextDraft = useCallback(() => { + textDraftRef.current = null + textValueRef.current = '' + textCompositionRef.current = false + setTextDraft(null) + setTextValue('') + }, []) + + const pendingTextOp = useMemo( + () => createImageAnnotationTextOp(textDraft, textValue, color, fontSize), + [color, fontSize, textDraft, textValue] + ) + + const commitText = useCallback(() => { + const draft = textDraftRef.current + const value = textValueRef.current + const op = createImageAnnotationTextOp(draft, value, color, fontSize) + cancelTextDraft() + if (!op) return + setOps((prev) => [...prev, op]) + }, [cancelTextDraft, color, fontSize]) + const onPointerDown = useCallback( (e: React.PointerEvent) => { if (!loaded || busy) return const p = toCanvasPoint(e) if (tool === 'text') { + e.preventDefault() + e.stopPropagation() + if (textDraftRef.current) { + commitText() + return + } const rect = e.currentTarget.getBoundingClientRect() - setTextValue('') - setTextDraft({ cssX: e.clientX - rect.left, cssY: e.clientY - rect.top, x: p.x, y: p.y }) + const draft = createImageAnnotationTextDraftAtCssPoint({ + canvasWidth: e.currentTarget.width, + canvasHeight: e.currentTarget.height, + cssWidth: rect.width, + cssHeight: rect.height, + cssX: e.clientX - rect.left, + cssY: e.clientY - rect.top, + canvasFontSize: fontSize + }) + if (draft) openTextDraft(draft) return } e.currentTarget.setPointerCapture(e.pointerId) @@ -248,7 +416,7 @@ export function ImageAnnotationEditor({ } rerender() }, - [busy, color, loaded, rerender, strokeWidth, tool, toCanvasPoint] + [busy, color, commitText, fontSize, loaded, openTextDraft, rerender, strokeWidth, tool, toCanvasPoint] ) const onPointerMove = useCallback( @@ -278,46 +446,37 @@ export function ImageAnnotationEditor({ setOps((prev) => [...prev, op]) }, [rerender]) - const commitText = useCallback(() => { - const draft = textDraft - const value = textValue.trim() - setTextDraft(null) - setTextValue('') - if (!draft || !value) return - setOps((prev) => [...prev, { kind: 'text', color, x: draft.x, y: draft.y, text: value, fontSize }]) - }, [color, fontSize, textDraft, textValue]) - const undo = useCallback(() => setOps((prev) => prev.slice(0, -1)), []) const clearAll = useCallback(() => setOps([]), []) - const textNotes = useMemo( - () => ops.filter((op): op is Extract => op.kind === 'text').map((op) => op.text), - [ops] - ) - const apply = useCallback(() => { const canvas = canvasRef.current if (!canvas || !loaded || busy) return + const appliedOps = pendingTextOp ? [...ops, pendingTextOp] : ops // Repaint once without any in-progress drag so the export is committed-only. const ctx = canvas.getContext('2d') const img = imageRef.current if (ctx && img) { ctx.clearRect(0, 0, canvas.width, canvas.height) ctx.drawImage(img, 0, 0, canvas.width, canvas.height) - for (const op of ops) paintOp(ctx, op) + for (const op of appliedOps) paintOp(ctx, op) } const dataUrl = canvas.toDataURL('image/png') const dataBase64 = dataUrl.replace(/^data:image\/png;base64,/, '') - onApply({ dataBase64, mimeType: 'image/png', textNotes, instruction: instruction.trim() }) - }, [busy, instruction, loaded, onApply, ops, textNotes]) + onApply({ + dataBase64, + mimeType: 'image/png', + textNotes: imageAnnotationTextNotes(appliedOps), + instruction: instruction.trim() + }) + }, [busy, instruction, loaded, onApply, ops, pendingTextOp]) // Esc cancels (unless typing a text annotation, where Esc cancels the draft). useEffect(() => { const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') { if (textDraft) { - setTextDraft(null) - setTextValue('') + cancelTextDraft() } else if (!busy) { onCancel() } @@ -325,10 +484,10 @@ export function ImageAnnotationEditor({ } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [busy, onCancel, textDraft]) + }, [busy, cancelTextDraft, onCancel, textDraft]) // Apply needs *some* instruction — drawn markup, or at least a typed note. - const canApply = ops.length > 0 || Boolean(dragRef.current) || instruction.trim().length > 0 + const canApply = ops.length > 0 || Boolean(dragRef.current) || Boolean(pendingTextOp) || instruction.trim().length > 0 return (
@@ -364,31 +523,74 @@ export function ImageAnnotationEditor({
) : null} -
+
{textDraft ? ( - setTextValue(e.target.value)} + rows={1} + wrap="off" + spellCheck={false} + autoCapitalize="off" + autoCorrect="off" + onChange={(e) => { + const nextValue = e.target.value + textValueRef.current = nextValue + setTextValue(nextValue) + resizeTextEditor(e.currentTarget, textDraft) + }} + onCompositionStart={() => { + textCompositionRef.current = true + }} + onCompositionEnd={(e) => { + const nextValue = e.currentTarget.value + textCompositionRef.current = false + textValueRef.current = nextValue + setTextValue(nextValue) + }} + onPointerDown={(e) => { + e.stopPropagation() + }} onBlur={commitText} onKeyDown={(e) => { - if (e.key === 'Enter') { + e.stopPropagation() + if ( + shouldCommitImageAnnotationTextKey( + e.key, + e.nativeEvent.isComposing, + textCompositionRef.current, + e.metaKey || e.ctrlKey + ) + ) { e.preventDefault() commitText() } }} - placeholder="输入文字后回车" - className="absolute z-10 min-w-[120px] rounded-md border border-white/70 bg-black/60 px-2 py-1 text-[13px] text-white outline-none placeholder:text-white/45" - style={{ left: textDraft.cssX, top: textDraft.cssY }} + placeholder="输入文字" + className="ds-no-drag absolute z-10 block resize-none overflow-hidden border-0 bg-transparent p-0 font-semibold outline-none placeholder:text-current/35" + style={{ + left: textDraft.cssX, + top: textDraft.cssY, + maxWidth: textDraft.maxCssWidth, + minHeight: textDraft.cssLineHeight, + fontFamily: 'Inter, system-ui, sans-serif', + fontSize: textDraft.cssFontSize, + lineHeight: `${textDraft.cssLineHeight}px`, + color, + textShadow: color === '#ffffff' ? '0 0 2px rgba(0,0,0,0.65)' : '0 0 2px rgba(255,255,255,0.95)' + }} /> ) : null}
diff --git a/src/renderer/src/components/settings-section-agents.test.ts b/src/renderer/src/components/settings-section-agents.test.ts index 00298b0e8..0866596ef 100644 --- a/src/renderer/src/components/settings-section-agents.test.ts +++ b/src/renderer/src/components/settings-section-agents.test.ts @@ -46,6 +46,11 @@ const labels: Record = { modelProviderApiKeyPlaceholder: 'Enter provider API key', modelProviderBaseUrl: 'Provider base URL', modelProviderEndpointFormat: 'Endpoint format', + modelProviderRetrySection: 'Failure retry', + modelProviderRetryMaxAttempts: 'Retry attempts', + modelProviderRetryInitialDelayMs: 'Initial retry delay (ms)', + modelProviderRetryStatusCodes: 'Retry HTTP status codes', + modelProviderRetryStatusCodesHint: 'Separate multiple status codes with commas, for example 429,503.', modelProviderFetchEmpty: 'No models found', modelEndpointChatCompletions: '/v1/chat/completions (openai)', modelEndpointResponses: '/v1/responses (openai)', @@ -178,6 +183,7 @@ const labels: Record = { kunMemoryRecordsDesc: 'Memory records description', kunMemoryEmpty: 'No memories', kunMemoryDisable: 'Disable memory', + memoryRestore: 'Restore', kunMemoryDelete: 'Delete memory', kunMemoryDisabled: 'Disabled', skill: 'Skill', @@ -524,6 +530,45 @@ describe('AgentsSettingsSection Kun diagnostics smoke', () => { expect(html).toContain('No API key') }) + it('renders retry status codes without spaces and explains comma separation before retry fields', () => { + const provider = defaultModelProviderSettings() + const customProvider = { + id: 'retry-provider', + name: 'Retry Provider', + apiKey: 'sk-test', + baseUrl: 'https://api.example.com/v1', + endpointFormat: 'chat_completions', + retry: { + maxAttempts: 3, + initialDelayMs: 3000, + httpStatusCodes: [429, 503] + }, + models: ['retry-model'], + modelProfiles: {} + } satisfies ModelProviderProfileV1 + const html = renderToStaticMarkup(createElement(ProvidersSettingsSection, { + ctx: { + ...baseCtx(), + provider: { + ...provider, + providers: [...provider.providers, customProvider] + }, + kun: { + ...defaultKunRuntimeSettings(), + providerId: customProvider.id + } + } + })) + + expect(html).toContain('Failure retry') + expect(html).toContain('Retry HTTP status codes') + expect(html).toContain('value="429,503"') + expect(html).not.toContain('value="429, 503"') + expect(html).toContain('Separate multiple status codes with commas, for example 429,503.') + expect(html.indexOf('Separate multiple status codes with commas, for example 429,503.')) + .toBeLessThan(html.indexOf('Retry attempts')) + }) + it('locks preset and default provider ids and shows the danger zone only for removable providers', () => { const provider = defaultModelProviderSettings() const xiaomi = getModelProviderPreset('xiaomi') @@ -648,7 +693,8 @@ describe('AgentsSettingsSection Kun diagnostics smoke', () => { id: 'mem_1', content: 'Prefer pnpm for this workspace', scope: 'workspace', - tags: ['tooling'] + tags: ['tooling'], + disabledAt: '2026-06-21T01:00:00.000Z' } ] } @@ -667,7 +713,8 @@ describe('AgentsSettingsSection Kun diagnostics smoke', () => { expect(html).toContain('Discovered Skills') expect(html).toContain('Prefer pnpm for this workspace') expect(html).toContain('mem_1') - expect(html).toContain('Disable memory') + expect(html).toContain('aria-label="Restore"') + expect(html).not.toContain('aria-label="Disable memory"') expect(html).toContain('Delete memory') }) diff --git a/src/renderer/src/components/settings-section-agents.tsx b/src/renderer/src/components/settings-section-agents.tsx index e243f7c6b..0265ff7d2 100644 --- a/src/renderer/src/components/settings-section-agents.tsx +++ b/src/renderer/src/components/settings-section-agents.tsx @@ -6,6 +6,7 @@ import type { } from '@shared/app-settings' import { DEFAULT_MODEL_PROVIDER_ID, + DEFAULT_PROMPT_OPTIMIZATION_PROMPT, DEFAULT_WRITE_INLINE_COMPLETION_BASE_URL, DEFAULT_WRITE_INLINE_COMPLETION_MAX_TOKENS, DEFAULT_WRITE_INLINE_COMPLETION_MODEL, @@ -569,6 +570,35 @@ export function AgentsSettingsSection({ ctx }: { ctx: Record }): Re const activeProviderId = kun.providerId?.trim() || DEFAULT_MODEL_PROVIDER_ID const activeProvider = modelProviders.find((item) => item.id === activeProviderId) ?? modelProviders[0] const activeProviderModels = activeProvider?.models ?? [] + const promptOptimization = { + enabled: false, + providerId: '', + model: '', + prompt: '', + timeoutMs: 60000, + ...(kun.promptOptimization ?? {}) + } + const promptOptimizationProviderId = promptOptimization.providerId?.trim() || activeProviderId + const promptOptimizationProvider = + modelProviders.find((item) => item.id === promptOptimizationProviderId) ?? activeProvider + const promptOptimizationModels = promptOptimizationProvider?.models ?? [] + const promptOptimizationDefaultModel = (() => { + const providerId = promptOptimizationProvider?.id ?? promptOptimizationProviderId + const smallModel = kun.smallModel?.trim() ?? '' + const smallProviderId = kun.smallModelProviderId?.trim() || activeProviderId + if (smallModel && smallProviderId === providerId) return smallModel + const mainModel = kun.model?.trim() ?? '' + if (mainModel && activeProviderId === providerId) return mainModel + return promptOptimizationModels[0] ?? mainModel + })() + const updatePromptOptimization = (patch: Record): void => { + updateKun({ + promptOptimization: { + ...promptOptimization, + ...patch + } + }) + } const selectKunProvider = (providerId: string): void => { const nextProvider = modelProviders.find((item) => item.id === providerId) ?? activeProvider const nextModel = nextProvider?.models.includes(kun.model) @@ -652,6 +682,87 @@ export function AgentsSettingsSection({ ctx }: { ctx: Record }): Re /> } /> + updatePromptOptimization({ enabled })} + /> + } + /> + {promptOptimization.enabled ? ( + + + + +