From 5086e1799312661b4d23f4902c941e24a0225b56 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sat, 4 Jul 2026 11:16:22 +0800 Subject: [PATCH 01/44] feat: update showPostUpdateReleaseNotes to prevent showing notes in development and when version is not greater --- src/main/gui-updater.test.ts | 35 ++++++++++++++++++++++++++++++++++- src/main/gui-updater.ts | 3 +++ 2 files changed, 37 insertions(+), 1 deletion(-) 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 From a69d25d7c1ce4836eff79b2572a58dc7832c1cb2 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sat, 4 Jul 2026 11:16:41 +0800 Subject: [PATCH 02/44] feat: add image annotation text operations and handling for IME composition --- .../adapters/tool/image-gen-tool-provider.ts | 18 +++-- kun/tests/image-gen-tool-provider.test.ts | 57 ++++++++++++- .../canvas/ImageAnnotationEditor.test.ts | 55 ++++++++++++- .../design/canvas/ImageAnnotationEditor.tsx | 79 +++++++++++++++---- 4 files changed, 181 insertions(+), 28 deletions(-) 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/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/src/renderer/src/components/design/canvas/ImageAnnotationEditor.test.ts b/src/renderer/src/components/design/canvas/ImageAnnotationEditor.test.ts index 6fcb406c6..67dc33b2b 100644 --- a/src/renderer/src/components/design/canvas/ImageAnnotationEditor.test.ts +++ b/src/renderer/src/components/design/canvas/ImageAnnotationEditor.test.ts @@ -1,7 +1,12 @@ import { createElement } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it } from 'vitest' -import { ImageAnnotationEditor } from './ImageAnnotationEditor' +import { + createImageAnnotationTextOp, + ImageAnnotationEditor, + imageAnnotationTextNotes, + shouldCommitImageAnnotationTextKey +} from './ImageAnnotationEditor' function renderEditor(): string { return renderToStaticMarkup( @@ -34,3 +39,51 @@ describe('ImageAnnotationEditor layout', () => { expect(html).not.toContain('bg-white/12') }) }) + +describe('ImageAnnotationEditor text annotations', () => { + it('creates trimmed pending text annotations', () => { + expect( + createImageAnnotationTextOp( + { cssX: 10, cssY: 12, x: 100, y: 120 }, + ' 改成蓝色 ', + '#3b82f6', + 36 + ) + ).toEqual({ + kind: 'text', + color: '#3b82f6', + x: 100, + y: 120, + text: '改成蓝色', + fontSize: 36 + }) + + expect(createImageAnnotationTextOp(null, '改成蓝色', '#3b82f6', 36)).toBeNull() + expect(createImageAnnotationTextOp({ cssX: 0, cssY: 0, x: 0, y: 0 }, ' ', '#3b82f6', 36)).toBeNull() + }) + + it('extracts text notes from committed and pending operations', () => { + const textOp = createImageAnnotationTextOp( + { cssX: 10, cssY: 12, x: 100, y: 120 }, + '标题放大', + '#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(true) + expect(shouldCommitImageAnnotationTextKey('Enter', true, false)).toBe(false) + expect(shouldCommitImageAnnotationTextKey('Enter', false, true)).toBe(false) + expect(shouldCommitImageAnnotationTextKey('a', false, false)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/design/canvas/ImageAnnotationEditor.tsx b/src/renderer/src/components/design/canvas/ImageAnnotationEditor.tsx index 02fa9eff6..d9b25477a 100644 --- a/src/renderer/src/components/design/canvas/ImageAnnotationEditor.tsx +++ b/src/renderer/src/components/design/canvas/ImageAnnotationEditor.tsx @@ -21,6 +21,8 @@ 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 } + export type ImageAnnotationResult = { /** Base64 PNG bytes of the flattened picture + markup (no `data:` prefix). */ dataBase64: string @@ -58,6 +60,31 @@ 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 +): boolean { + return key === 'Enter' && !nativeIsComposing && !activeComposition +} + export const IMAGE_ANNOTATION_ROOT_CLASS = 'ds-no-drag fixed inset-0 z-[200] flex flex-col bg-black/75 backdrop-blur-sm' @@ -152,10 +179,9 @@ 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 textCompositionRef = useRef(false) // Live drag state for the in-progress shape (rubber-band preview). const dragRef = useRef<{ op: AnnotationOp } | null>(null) @@ -278,38 +304,44 @@ export function ImageAnnotationEditor({ setOps((prev) => [...prev, op]) }, [rerender]) + const pendingTextOp = useMemo( + () => createImageAnnotationTextOp(textDraft, textValue, color, fontSize), + [color, fontSize, textDraft, textValue] + ) + const commitText = useCallback(() => { - const draft = textDraft - const value = textValue.trim() + const op = createImageAnnotationTextOp(textDraft, textValue, color, fontSize) setTextDraft(null) setTextValue('') - if (!draft || !value) return - setOps((prev) => [...prev, { kind: 'text', color, x: draft.x, y: draft.y, text: value, fontSize }]) + textCompositionRef.current = false + if (!op) return + setOps((prev) => [...prev, op]) }, [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(() => { @@ -328,7 +360,7 @@ export function ImageAnnotationEditor({ }, [busy, 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 (
@@ -379,9 +411,22 @@ export function ImageAnnotationEditor({ autoFocus value={textValue} onChange={(e) => setTextValue(e.target.value)} + onCompositionStart={() => { + textCompositionRef.current = true + }} + onCompositionEnd={(e) => { + textCompositionRef.current = false + setTextValue(e.currentTarget.value) + }} onBlur={commitText} onKeyDown={(e) => { - if (e.key === 'Enter') { + if ( + shouldCommitImageAnnotationTextKey( + e.key, + e.nativeEvent.isComposing, + textCompositionRef.current + ) + ) { e.preventDefault() commitText() } From f9af2e142db3f69f9932bfeb180d78b75f5e4c39 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Sat, 4 Jul 2026 12:06:38 +0800 Subject: [PATCH 03/44] feat: enhance text annotation functionality with draft creation and resizing --- .../canvas/ImageAnnotationEditor.test.ts | 98 ++++++- .../design/canvas/ImageAnnotationEditor.tsx | 241 +++++++++++++++--- 2 files changed, 291 insertions(+), 48 deletions(-) diff --git a/src/renderer/src/components/design/canvas/ImageAnnotationEditor.test.ts b/src/renderer/src/components/design/canvas/ImageAnnotationEditor.test.ts index 67dc33b2b..dd9b0a547 100644 --- a/src/renderer/src/components/design/canvas/ImageAnnotationEditor.test.ts +++ b/src/renderer/src/components/design/canvas/ImageAnnotationEditor.test.ts @@ -2,12 +2,23 @@ import { createElement } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it } from 'vitest' 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( createElement(ImageAnnotationEditor, { @@ -44,7 +55,7 @@ describe('ImageAnnotationEditor text annotations', () => { it('creates trimmed pending text annotations', () => { expect( createImageAnnotationTextOp( - { cssX: 10, cssY: 12, x: 100, y: 120 }, + draft, ' 改成蓝色 ', '#3b82f6', 36 @@ -59,12 +70,12 @@ describe('ImageAnnotationEditor text annotations', () => { }) expect(createImageAnnotationTextOp(null, '改成蓝色', '#3b82f6', 36)).toBeNull() - expect(createImageAnnotationTextOp({ cssX: 0, cssY: 0, x: 0, y: 0 }, ' ', '#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( - { cssX: 10, cssY: 12, x: 100, y: 120 }, + draft, '标题放大', '#111827', 24 @@ -81,9 +92,84 @@ describe('ImageAnnotationEditor text annotations', () => { }) it('does not commit Enter while an IME composition is active', () => { - expect(shouldCommitImageAnnotationTextKey('Enter', false, false)).toBe(true) - expect(shouldCommitImageAnnotationTextKey('Enter', true, false)).toBe(false) - expect(shouldCommitImageAnnotationTextKey('Enter', false, true)).toBe(false) + 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 d9b25477a..b2685c12a 100644 --- a/src/renderer/src/components/design/canvas/ImageAnnotationEditor.tsx +++ b/src/renderer/src/components/design/canvas/ImageAnnotationEditor.tsx @@ -21,7 +21,15 @@ 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 } +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). */ @@ -52,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 }, @@ -80,9 +91,71 @@ export function imageAnnotationTextNotes(ops: readonly AnnotationOp[]): string[] export function shouldCommitImageAnnotationTextKey( key: string, nativeIsComposing: boolean, - activeComposition: boolean + activeComposition: boolean, + ctrlOrMetaKey = false ): boolean { - return key === 'Enter' && !nativeIsComposing && !activeComposition + 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 = @@ -150,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({ @@ -181,6 +246,9 @@ export function ImageAnnotationEditor({ const [instruction, setInstruction] = useState('') 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). @@ -245,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 } @@ -254,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) @@ -274,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( @@ -304,20 +446,6 @@ export function ImageAnnotationEditor({ setOps((prev) => [...prev, op]) }, [rerender]) - const pendingTextOp = useMemo( - () => createImageAnnotationTextOp(textDraft, textValue, color, fontSize), - [color, fontSize, textDraft, textValue] - ) - - const commitText = useCallback(() => { - const op = createImageAnnotationTextOp(textDraft, textValue, color, fontSize) - setTextDraft(null) - setTextValue('') - textCompositionRef.current = false - if (!op) return - setOps((prev) => [...prev, op]) - }, [color, fontSize, textDraft, textValue]) - const undo = useCallback(() => setOps((prev) => prev.slice(0, -1)), []) const clearAll = useCallback(() => setOps([]), []) @@ -348,8 +476,7 @@ export function ImageAnnotationEditor({ const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') { if (textDraft) { - setTextDraft(null) - setTextValue('') + cancelTextDraft() } else if (!busy) { onCancel() } @@ -357,7 +484,7 @@ 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) || Boolean(pendingTextOp) || instruction.trim().length > 0 @@ -396,44 +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 - setTextValue(e.currentTarget.value) + textValueRef.current = nextValue + setTextValue(nextValue) + }} + onPointerDown={(e) => { + e.stopPropagation() }} onBlur={commitText} onKeyDown={(e) => { + e.stopPropagation() if ( shouldCommitImageAnnotationTextKey( e.key, e.nativeEvent.isComposing, - textCompositionRef.current + 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}
From 21cec5e7ccc4b206b9315345a67a11f217f24814 Mon Sep 17 00:00:00 2001 From: tsai <48399057+tsaiTop@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:29:12 +0800 Subject: [PATCH 04/44] fix(settings): drop legacy top-level instructions on normalize Summary:\n- Drop legacy top-level instructions during settings normalization.\n- Add regression coverage for stale instructions payloads.\n\nTests:\n- npm run test -- src/shared/app-settings.test.ts\n- git diff --check --- src/shared/app-settings-normalize.ts | 1 - src/shared/app-settings.test.ts | 9 +++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/shared/app-settings-normalize.ts b/src/shared/app-settings-normalize.ts index 78a7b396d..cf1872d76 100644 --- a/src/shared/app-settings-normalize.ts +++ b/src/shared/app-settings-normalize.ts @@ -75,7 +75,6 @@ export function normalizeAppSettings(settings: AppSettingsV1): AppSettingsV1 { kunPatch: rawMediaPatch }) return { - ...migrated, version: 1, locale: maybeSettings.locale === 'zh' ? 'zh' : 'en', theme: diff --git a/src/shared/app-settings.test.ts b/src/shared/app-settings.test.ts index 5abc0b50a..55170995c 100644 --- a/src/shared/app-settings.test.ts +++ b/src/shared/app-settings.test.ts @@ -962,6 +962,15 @@ describe('legacy Kun defaults migration', () => { })) }) + it('drops legacy top-level instructions during normalization', () => { + const normalized = normalizeAppSettings({ + ...settings(), + instructions: { enabled: true } + } as unknown as AppSettingsV1) + + expect('instructions' in normalized).toBe(false) + }) + it('moves the legacy local HTTP default port to the Kun default port', () => { const migrated = migrateLegacyAppSettings({ version: 1, From cf192ad6a619d9851a0903165d563581c4e9f3dd Mon Sep 17 00:00:00 2001 From: musnow Date: Sat, 4 Jul 2026 12:29:50 +0800 Subject: [PATCH 05/44] fix(memory): allow disabled records to be re-enabled Summary:\n- Add regression coverage for re-enabling disabled memory records through renderer and Kun memory paths.\n\nTests:\n- npm run test -- src/renderer/src/components/settings-section-memory.test.ts src/renderer/src/components/settings-section-agents.test.ts src/renderer/src/agent/kun-runtime.test.ts\n- npm --prefix kun run test -- src/memory/memory-store.test.ts\n- git diff --check --- kun/src/memory/memory-store.test.ts | 51 +++++++++++++++++++ src/renderer/src/agent/kun-runtime.test.ts | 18 +++++-- .../settings-section-agents.test.ts | 7 ++- 3 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 kun/src/memory/memory-store.test.ts 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/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/components/settings-section-agents.test.ts b/src/renderer/src/components/settings-section-agents.test.ts index 00298b0e8..7cb53efff 100644 --- a/src/renderer/src/components/settings-section-agents.test.ts +++ b/src/renderer/src/components/settings-section-agents.test.ts @@ -178,6 +178,7 @@ const labels: Record = { kunMemoryRecordsDesc: 'Memory records description', kunMemoryEmpty: 'No memories', kunMemoryDisable: 'Disable memory', + memoryRestore: 'Restore', kunMemoryDelete: 'Delete memory', kunMemoryDisabled: 'Disabled', skill: 'Skill', @@ -648,7 +649,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 +669,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') }) From b1a78215216854538ff532aad71ae2c12b5f00df Mon Sep 17 00:00:00 2001 From: musnow Date: Sat, 4 Jul 2026 12:29:59 +0800 Subject: [PATCH 06/44] fix(composer): collapse reasoning options Summary:\n- Collapse composer reasoning choices behind a submenu in the model picker.\n- Keep the selected reasoning effort visible in the main control.\n\nTests:\n- npm run test -- src/renderer/src/components/chat/FloatingComposer.test.ts\n- git diff --check --- .../chat/FloatingComposerModelPicker.tsx | 126 ++++++++++++++---- 1 file changed, 103 insertions(+), 23 deletions(-) diff --git a/src/renderer/src/components/chat/FloatingComposerModelPicker.tsx b/src/renderer/src/components/chat/FloatingComposerModelPicker.tsx index cb5cdb198..0b1070b43 100644 --- a/src/renderer/src/components/chat/FloatingComposerModelPicker.tsx +++ b/src/renderer/src/components/chat/FloatingComposerModelPicker.tsx @@ -115,8 +115,10 @@ export function FloatingComposerModelPicker({ const pickerRef = useRef(null) const menuRef = useRef(null) const submenuRef = useRef(null) + const reasoningRowRef = useRef(null) const providerRowRefs = useRef>(new Map()) const [menuOpen, setMenuOpen] = useState(false) + const [reasoningPanelOpen, setReasoningPanelOpen] = useState(false) const [activeProviderId, setActiveProviderId] = useState(null) const [modelFilter, setModelFilter] = useState('') const [menuPlacement, setMenuPlacement] = useState(null) @@ -200,6 +202,7 @@ export function FloatingComposerModelPicker({ if (!menuOpen) { setMenuPlacement(null) setSubmenuPlacement(null) + setReasoningPanelOpen(false) setModelFilter('') return } @@ -231,6 +234,7 @@ export function FloatingComposerModelPicker({ useEffect(() => { if (!menuOpen) { setActiveProviderId(null) + setReasoningPanelOpen(false) return } if (providerMenuGroups.length === 0) { @@ -244,13 +248,17 @@ export function FloatingComposerModelPicker({ }, [menuOpen, providerMenuGroups]) useEffect(() => { - if (!menuOpen || !activeProviderGroup) { + if (!menuOpen || (!reasoningPanelOpen && !activeProviderGroup)) { setSubmenuPlacement(null) return } const updatePlacement = (): void => { - const row = providerRowRefs.current.get(activeProviderGroup.providerId) + const row = reasoningPanelOpen + ? reasoningRowRef.current + : activeProviderGroup + ? providerRowRefs.current.get(activeProviderGroup.providerId) + : null if (!row) return setSubmenuPlacement( @@ -258,7 +266,9 @@ export function FloatingComposerModelPicker({ anchorRect: row.getBoundingClientRect(), submenuHeight: submenuRef.current?.offsetHeight - || estimatedModelSubmenuHeight(activeProviderModelIds.length), + || (reasoningPanelOpen + ? estimatedReasoningSubmenuHeight(reasoningOptions.length) + : estimatedModelSubmenuHeight(activeProviderModelIds.length)), viewportHeight: window.innerHeight, viewportWidth: window.innerWidth, coordinateScale: currentBodyZoom() @@ -276,7 +286,7 @@ export function FloatingComposerModelPicker({ window.removeEventListener('resize', updatePlacement) window.removeEventListener('scroll', updatePlacement, true) } - }, [activeProviderGroup, activeProviderModelIds.length, menuOpen]) + }, [activeProviderGroup, activeProviderModelIds.length, menuOpen, reasoningOptions.length, reasoningPanelOpen]) const menuStyle: CSSProperties = menuPlacement ? { @@ -320,22 +330,24 @@ export function FloatingComposerModelPicker({ > {reasoningEnabled && !needsProviderSetup ? ( <> - }> - {t('composerReasoning')} - -
- {reasoningOptions.map((option) => ( - { - onComposerReasoningEffortChange?.(option.id) - setMenuOpen(false) - }} - /> - ))} -
+ { + reasoningRowRef.current = node + }} + active={reasoningPanelOpen} + selected={false} + icon={} + title={t('composerReasoning')} + subtitle={currentReasoningLabel} + onClick={() => { + setActiveProviderId(null) + setReasoningPanelOpen((open) => !open) + }} + onMouseEnter={() => { + setActiveProviderId(null) + setReasoningPanelOpen(true) + }} + /> ) : null} @@ -383,15 +395,46 @@ export function FloatingComposerModelPicker({ selected={selectedProviderId === group.providerId} title={group.label} subtitle={selectedModel} - onClick={() => setActiveProviderId(group.providerId)} - onMouseEnter={() => setActiveProviderId(group.providerId)} + onClick={() => { + setReasoningPanelOpen(false) + setActiveProviderId(group.providerId) + }} + onMouseEnter={() => { + setReasoningPanelOpen(false) + setActiveProviderId(group.providerId) + }} /> ) }) )}
- {activeProviderGroup ? ( + {reasoningPanelOpen && reasoningEnabled ? ( +
+
+ {t('composerReasoning')} +
+
+ {reasoningOptions.map((option) => ( + { + onComposerReasoningEffortChange?.(option.id) + setMenuOpen(false) + }} + /> + ))} +
+
+ ) : activeProviderGroup ? (
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 ? ( +
+
{jumpRailPreview.title}
+
{jumpRailPreview.prompt}
+
+ ) : null}
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..233044b33 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,5 @@ import { describe, expect, it } from 'vitest' -import { activeTimelineTurnKey } from './MessageTimeline' +import { activeTimelineTurnKey, timelineJumpRailLeft, timelineJumpWaveLevel } from './MessageTimeline' describe('activeTimelineTurnKey', () => { const positions = [ @@ -23,3 +23,19 @@ 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(300, 1000, 800)).toBe(382) + }) + + it('reserves space when the requested content width is wider than the stage', () => { + expect(timelineJumpRailLeft(300, 1000, 1200)).toBe(324) + }) +}) diff --git a/src/renderer/src/styles/base-shell.css b/src/renderer/src/styles/base-shell.css index eb54d768f..ab73cbba4 100644 --- a/src/renderer/src/styles/base-shell.css +++ b/src/renderer/src/styles/base-shell.css @@ -2850,40 +2850,42 @@ pre { } .ds-chat-column-inset { - padding-inline: clamp(0.75rem, calc((100% - var(--ds-chat-content-max-width, 56rem)) / 2 + 1rem), 2rem); + padding-inline: clamp(0.75rem, calc((100% - var(--ds-chat-content-effective-max-width, var(--ds-chat-content-max-width, 56rem))) / 2 + 1rem), 2rem); } .ds-chat-content-max-width { - max-width: var(--ds-chat-content-max-width, 56rem); + max-width: var(--ds-chat-content-effective-max-width, var(--ds-chat-content-max-width, 56rem)); } .ds-chat-stage { container-type: inline-size; + --ds-chat-side-rail-reserve: 5.25rem; + --ds-chat-content-effective-max-width: min( + var(--ds-chat-content-max-width, 56rem), + max(0px, calc(100cqw - var(--ds-chat-side-rail-reserve))) + ); } .timeline-jump-rail { position: fixed; - top: 7.6rem; - right: 1.55rem; + top: 50%; + left: 1rem; z-index: 15; display: flex; box-sizing: border-box; - width: 2.4rem; - min-width: 2.4rem; - max-height: min(48vh, 22rem); + width: 1.85rem; + min-width: 1.85rem; + max-height: min(54vh, 25rem); flex-direction: column; - align-items: center; - gap: 0.3rem; + align-items: flex-start; + gap: 0.22rem; overflow-y: auto; - border: 1px solid color-mix(in srgb, var(--ds-border) 82%, transparent); + border: 0; border-radius: 999px; - background: color-mix(in srgb, var(--ds-surface-card) 96%, transparent); - padding: 0.34rem; - box-shadow: - 0 12px 34px rgba(15, 23, 42, 0.16), - inset 0 1px 0 rgba(255, 255, 255, 0.12); - backdrop-filter: blur(20px); + background: transparent; + padding: 0.28rem 0.18rem; scrollbar-width: none; + transform: translateY(-50%); } .timeline-jump-rail::-webkit-scrollbar { @@ -2891,39 +2893,99 @@ pre { } .timeline-jump-rail-button { - display: inline-flex; + display: block; box-sizing: border-box; - width: 1.7rem; - min-width: 1.7rem; - height: 1.7rem; - align-items: center; - justify-content: center; + width: calc(0.22rem + var(--timeline-wave-width, 0.55rem)); + min-width: 0; + height: 0.18rem; + min-height: 0.18rem; + margin-left: 0; padding: 0; border: 0; border-radius: 999px; - background: color-mix(in srgb, var(--ds-text) 7%, transparent); - color: var(--ds-text-muted); - font-size: 0.76rem; - font-weight: 700; - font-variant-numeric: tabular-nums; - line-height: 1; - white-space: nowrap; - text-align: center; + background: color-mix(in srgb, var(--ds-text-muted) 52%, transparent); transition: - background 140ms ease, - color 140ms ease, - transform 140ms ease; + background-color 140ms ease, + box-shadow 140ms ease, + opacity 140ms ease, + transform 140ms ease, + width 140ms ease; +} + +.timeline-jump-rail-button[data-wave-level='1'] { + --timeline-wave-width: 0.32rem; +} + +.timeline-jump-rail-button[data-wave-level='2'] { + --timeline-wave-width: 0.5rem; +} + +.timeline-jump-rail-button[data-wave-level='3'] { + --timeline-wave-width: 0.72rem; +} + +.timeline-jump-rail-button[data-wave-level='4'] { + --timeline-wave-width: 0.95rem; +} + +.timeline-jump-rail-button[data-wave-level='5'] { + --timeline-wave-width: 1.18rem; } .timeline-jump-rail-button:hover, .timeline-jump-rail-button:focus-visible, .timeline-jump-rail-button.is-active { - background: var(--ds-accent-soft); - color: var(--ds-accent); - transform: translateY(-1px); + width: 1.28rem; + background: var(--ds-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--ds-accent) 12%, transparent); + opacity: 1; + transform: none; outline: none; } +.timeline-jump-rail-preview { + position: fixed; + z-index: 45; + box-sizing: border-box; + width: min(26rem, calc(100vw - 5.5rem)); + max-height: min(15rem, calc(100vh - 2rem)); + overflow: hidden; + transform: translateY(-50%); + border: 1px solid color-mix(in srgb, var(--ds-border) 82%, transparent); + border-radius: 18px; + background: color-mix(in srgb, var(--ds-surface-card) 94%, rgba(20, 20, 22, 0.9)); + padding: 0.9rem 1rem; + color: var(--ds-text); + box-shadow: + 0 18px 46px rgba(15, 23, 42, 0.22), + inset 0 1px 0 rgba(255, 255, 255, 0.1); + backdrop-filter: blur(20px); + pointer-events: none; +} + +.timeline-jump-rail-preview-title { + overflow: hidden; + color: var(--ds-text); + font-size: 0.92rem; + font-weight: 750; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.timeline-jump-rail-preview-text { + display: -webkit-box; + margin-top: 0.5rem; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; + color: var(--ds-text-muted); + font-size: 0.86rem; + font-weight: 600; + line-height: 1.55; + overflow-wrap: anywhere; +} + .ds-plan-panel-overlay { position: fixed; inset: 0; @@ -2962,6 +3024,10 @@ pre { display: none; } + .timeline-jump-rail-preview { + display: none; + } + .ds-plan-panel-overlay { padding: 0.5rem; } From 9b9bdc94baceed433c723556a5bf11ea4c07759a Mon Sep 17 00:00:00 2001 From: musnow Date: Sat, 4 Jul 2026 12:30:24 +0800 Subject: [PATCH 08/44] feat(provider): add configurable model request retry Summary:\n- Add provider-level retry settings for model requests and wire them through GUI settings, IPC, Kun config, and runtime clients.\n- Emit retry status events into the chat timeline while retryable model responses back off.\n\nTests:\n- npm --prefix kun run test -- tests/model-client.test.ts tests/contracts.test.ts\n- npm run test -- src/renderer/src/components/settings-section-agents.test.ts src/renderer/src/agent/kun-mapper.test.ts src/renderer/src/store/chat-store-runtime.test.ts src/main/ipc/app-ipc-schemas.test.ts\n- npm run typecheck\n- npm run build:kun\n- git diff --check --- kun/src/adapters/model/compat-model-client.ts | 83 +++++++++-- kun/src/cli/cli-options.ts | 2 + kun/src/cli/serve.ts | 1 + kun/src/config/kun-config.ts | 17 +++ kun/src/contracts/events.ts | 11 ++ kun/src/loop/agent-loop.ts | 11 ++ kun/src/ports/model-client.ts | 1 + kun/src/server/runtime-factory.ts | 5 + kun/tests/contracts.test.ts | 12 +- kun/tests/model-client.test.ts | 140 +++++++++++++++++- src/main/ipc/app-ipc-schemas.test.ts | 33 +++++ src/main/ipc/app-ipc-schemas.ts | 10 ++ src/main/kun-process.ts | 3 + src/renderer/src/agent/kun-contract.ts | 13 +- src/renderer/src/agent/kun-mapper.test.ts | 42 +++++- src/renderer/src/agent/kun-mapper.ts | 86 ++++++----- src/renderer/src/agent/types.ts | 5 + .../settings-section-agents.test.ts | 44 ++++++ .../components/settings-section-providers.tsx | 94 ++++++++++++ src/renderer/src/locales/en/common.json | 1 + src/renderer/src/locales/en/settings.json | 5 + src/renderer/src/locales/zh/common.json | 1 + src/renderer/src/locales/zh/settings.json | 5 + .../src/store/chat-store-runtime.test.ts | 31 ++++ src/renderer/src/store/chat-store-runtime.ts | 28 ++-- src/shared/app-settings-kun.ts | 9 ++ src/shared/app-settings-provider.test.ts | 37 +++++ src/shared/app-settings-provider.ts | 43 ++++++ src/shared/app-settings-types.ts | 13 ++ src/shared/model-provider-presets.ts | 15 ++ 30 files changed, 730 insertions(+), 71 deletions(-) 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/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..22dc7f5bd 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', @@ -177,6 +178,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 +295,7 @@ export const RuntimeEvent = z.discriminatedUnion('kind', [ ApprovalEvent, UserInputEvent, ToolCallReadyEvent, + ModelRequestRetryEvent, ToolUploadStatusEvent, ToolStormSuppressedEvent, ToolCatalogEvent, diff --git a/kun/src/loop/agent-loop.ts b/kun/src/loop/agent-loop.ts index e7b8c14fd..8f5179b40 100644 --- a/kun/src/loop/agent-loop.ts +++ b/kun/src/loop/agent-loop.ts @@ -1754,6 +1754,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) 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/server/runtime-factory.ts b/kun/src/server/runtime-factory.ts index c00fe42a8..cd68195c7 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 @@ -978,6 +980,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 +998,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 +1034,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/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/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/src/main/ipc/app-ipc-schemas.test.ts b/src/main/ipc/app-ipc-schemas.test.ts index d37e0b83d..4031fd7be 100644 --- a/src/main/ipc/app-ipc-schemas.test.ts +++ b/src/main/ipc/app-ipc-schemas.test.ts @@ -341,6 +341,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..71d688ae3 100644 --- a/src/main/ipc/app-ipc-schemas.ts +++ b/src/main/ipc/app-ipc-schemas.ts @@ -302,6 +302,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 +391,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(), diff --git a/src/main/kun-process.ts b/src/main/kun-process.ts index f4e841f8d..c3762047d 100644 --- a/src/main/kun-process.ts +++ b/src/main/kun-process.ts @@ -474,6 +474,7 @@ export async function syncGuiManagedKunConfig( KunRuntimeSettingsV1, | 'apiKey' | 'mcpSearch' + | 'retry' | 'tokenEconomy' | 'toolOutputLimits' | 'storage' @@ -555,6 +556,7 @@ export async function syncGuiManagedKunConfig( serve: { ...serve, storage, + retry: runtime.retry, tokenEconomy: tokenEconomyConfigForRuntime(runtime.tokenEconomy, existingTokenEconomy), toolOutputLimits: toolOutputLimitsConfigForRuntime(runtime.toolOutputLimits), headers: defaultClientHeaders, @@ -921,6 +923,7 @@ function providersConfigForRuntime(settings: AppSettingsV1): Record { 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', () => { diff --git a/src/renderer/src/agent/kun-mapper.ts b/src/renderer/src/agent/kun-mapper.ts index 6cde83cca..093f82363 100644 --- a/src/renderer/src/agent/kun-mapper.ts +++ b/src/renderer/src/agent/kun-mapper.ts @@ -1142,33 +1142,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 @@ -1233,16 +1246,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,7 +1317,7 @@ 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': diff --git a/src/renderer/src/agent/types.ts b/src/renderer/src/agent/types.ts index ed37d74fd..43dc84410 100644 --- a/src/renderer/src/agent/types.ts +++ b/src/renderer/src/agent/types.ts @@ -334,6 +334,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 +343,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/settings-section-agents.test.ts b/src/renderer/src/components/settings-section-agents.test.ts index 7cb53efff..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)', @@ -525,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') diff --git a/src/renderer/src/components/settings-section-providers.tsx b/src/renderer/src/components/settings-section-providers.tsx index bf111d92e..ca51d41e4 100644 --- a/src/renderer/src/components/settings-section-providers.tsx +++ b/src/renderer/src/components/settings-section-providers.tsx @@ -29,6 +29,7 @@ import { MODEL_PROVIDER_PRESETS, TOKEN_PLAN_PROVIDER_ID_SUFFIX, defaultMiniMaxMediaGenerationKunPatch, + defaultModelRequestRetrySettings, defaultModelProviderSettings, getModelProviderPreset, modelProviderPresetProfile, @@ -623,6 +624,26 @@ function CodexLoginSection({ const fieldLabelClass = 'grid gap-1.5 text-[12px] font-semibold text-ds-muted' const textInputClass = 'w-full min-w-0 rounded-xl border border-ds-border bg-ds-card px-3 py-2 text-[14px] font-normal text-ds-ink shadow-sm focus:border-accent/40 focus:outline-none focus:ring-1 focus:ring-accent/30' +const ENABLED_MODEL_REQUEST_RETRY_ATTEMPTS = 3 + +function retryStatusCodesText(codes: readonly number[] | undefined): string { + return (codes?.length ? codes : defaultModelRequestRetrySettings().httpStatusCodes).join(',') +} + +function providerRetrySettings(provider: ModelProviderProfileV1) { + return provider.retry ?? defaultModelRequestRetrySettings() +} + +function parseRetryStatusCodes(value: string): number[] { + const codes = new Set() + for (const part of value.split(/[\s,]+/)) { + const code = Number(part.trim()) + if (Number.isInteger(code) && code >= 400 && code <= 599) codes.add(code) + } + return codes.size > 0 + ? [...codes].sort((a, b) => a - b) + : defaultModelRequestRetrySettings().httpStatusCodes +} function DetailSection({ title, @@ -815,6 +836,7 @@ export function ProvidersSettingsSection({ ctx }: { ctx: Record }): const activeProvider = displayProviders.find((item) => item.id === selectedProviderId) ?? modelProviders[0] + const activeRetry = activeProvider ? providerRetrySettings(activeProvider) : defaultModelRequestRetrySettings() const isDraftActive = Boolean(draftProvider && activeProvider?.id === draftProvider.id) const canEditActiveProviderId = Boolean( activeProvider && @@ -1043,6 +1065,7 @@ export function ProvidersSettingsSection({ ctx }: { ctx: Record }): apiKey: '', baseUrl: 'https://api.example.com/v1', endpointFormat: 'chat_completions', + retry: defaultModelRequestRetrySettings(), models: [], modelProfiles: {} }) @@ -1714,6 +1737,77 @@ export function ProvidersSettingsSection({ ctx }: { ctx: Record }):

) : null} + 0} + onChange={(enabled) => updateModelProvider(activeProvider.id, { + retry: { + ...activeRetry, + maxAttempts: enabled ? ENABLED_MODEL_REQUEST_RETRY_ATTEMPTS : 0 + } + })} + /> + } + > + {activeRetry.maxAttempts > 0 ? ( +
+

+ {t('modelProviderRetryStatusCodesHint')} +

+
+ + + +
+
+ ) : null} +
{ + it('adds model request retry events as runtime status instead of a banner error', () => { + const { getState, set, get } = makeSinkHarness({ + activeThreadId: 'thread-current', + busy: true, + blocks: [{ kind: 'user', id: 'user-current', text: 'hello' }] + }) + const sink = buildThreadEventSink(set, get, { threadId: 'thread-current' }) + + sink.onRuntimeStatus?.({ + kind: 'model_request_retry', + itemId: 'runtime_status_turn-current_model_retry', + turnId: 'turn-current', + createdAt: '2026-06-08T00:00:00.000Z', + status: 429, + attempt: 1, + maxAttempts: 3, + delayMs: 3000 + }) + + const systemBlocks = getState().blocks.filter((block) => block.kind === 'system') + expect(systemBlocks).toHaveLength(1) + expect(systemBlocks[0]).toMatchObject({ + kind: 'system', + id: 'runtime_status_turn-current_model_retry' + }) + expect(systemBlocks[0].text).toContain('429') + expect(systemBlocks[0].text).toContain('1') + expect(systemBlocks[0].text).toContain('3') + expect(getState().error).toBeNull() + }) + it('adds runtime error events to the timeline with details', () => { const { getState, set, get } = makeSinkHarness({ activeThreadId: 'thread-current', diff --git a/src/renderer/src/store/chat-store-runtime.ts b/src/renderer/src/store/chat-store-runtime.ts index e366947d4..1b5081cdb 100644 --- a/src/renderer/src/store/chat-store-runtime.ts +++ b/src/renderer/src/store/chat-store-runtime.ts @@ -479,19 +479,27 @@ function runtimeStatusText(event: RuntimeStatusEventPayload): string { if (event.kind === 'tool_result_upload_wait') { return i18n.t('common:toolUploadWaitStatus', { count: event.toolResultCount ?? 0 }) } - if (event.kind === 'tool_catalog_changed') { - return event.message?.trim() || i18n.t('common:toolCatalogChangedStatus') - } - if (event.kind === 'tool_storm_suppressed') { - return event.message?.trim() || i18n.t('common:toolStormSuppressedStatus', { - tool: event.toolName ?? 'tool' - }) - } + if (event.kind === 'model_request_retry') { + return i18n.t('common:modelRequestRetryStatus', { + status: event.status ?? '', + attempt: event.attempt ?? 0, + max: event.maxAttempts ?? 0, + seconds: Math.ceil((event.delayMs ?? 0) / 1000) + }) + } + if (event.kind === 'tool_catalog_changed') { + return event.message?.trim() || i18n.t('common:toolCatalogChangedStatus') + } + if (event.kind === 'tool_storm_suppressed') { + return event.message?.trim() || i18n.t('common:toolStormSuppressedStatus', { + tool: event.toolName ?? 'tool' + }) + } if (event.kind === 'compaction_summary_fallback') { return event.message?.trim() || i18n.t('common:compactionSummaryFallbackStatus') } - return event.message?.trim() || '' - } + return event.message?.trim() || '' +} function runtimeErrorPayloadToError(event: { message: string diff --git a/src/shared/app-settings-kun.ts b/src/shared/app-settings-kun.ts index 99232bc05..b2d3eb2de 100644 --- a/src/shared/app-settings-kun.ts +++ b/src/shared/app-settings-kun.ts @@ -9,6 +9,9 @@ import { DEFAULT_MUSIC_GENERATION_PROTOCOL, MIN_KUN_LOCAL_PORT, DEFAULT_MODEL_ENDPOINT_FORMAT, + DEFAULT_MODEL_REQUEST_RETRY_HTTP_STATUS_CODES, + DEFAULT_MODEL_REQUEST_RETRY_INITIAL_DELAY_MS, + DEFAULT_MODEL_REQUEST_RETRY_MAX_ATTEMPTS, DEFAULT_SANDBOX_MODE, DEFAULT_TOOL_OUTPUT_MAX_BYTES, DEFAULT_TOOL_OUTPUT_MAX_LINES, @@ -137,6 +140,11 @@ export function defaultKunRuntimeSettings( baseUrl: '', providerId: '', endpointFormat: DEFAULT_MODEL_ENDPOINT_FORMAT, + retry: { + maxAttempts: DEFAULT_MODEL_REQUEST_RETRY_MAX_ATTEMPTS, + initialDelayMs: DEFAULT_MODEL_REQUEST_RETRY_INITIAL_DELAY_MS, + httpStatusCodes: [...DEFAULT_MODEL_REQUEST_RETRY_HTTP_STATUS_CODES] + }, runtimeToken: '', dataDir: DEFAULT_KUN_DATA_DIR, model: DEFAULT_KUN_MODEL, @@ -1157,6 +1165,7 @@ export function migrateLegacyAppSettings(parsed: LegacyAppSettingsShape): Partia baseUrl: legacySource.baseUrl, providerId: '', endpointFormat: DEFAULT_MODEL_ENDPOINT_FORMAT, + retry: kunDefaults.retry, runtimeToken: isReasoningLegacy ? kunDefaults.runtimeToken : legacyLocalHttp.runtimeToken, model: isReasoningLegacy ? legacyReasoning.model : kunDefaults.model, approvalPolicy: isReasoningLegacy ? kunDefaults.approvalPolicy : legacyLocalHttp.approvalPolicy, diff --git a/src/shared/app-settings-provider.test.ts b/src/shared/app-settings-provider.test.ts index e17648503..da3591ee0 100644 --- a/src/shared/app-settings-provider.test.ts +++ b/src/shared/app-settings-provider.test.ts @@ -19,6 +19,7 @@ import { defaultWorkflowSettings, defaultTerminalSettings, defaultWriteSettings, + defaultModelRequestRetrySettings, listMusicGenerationProviderProfiles, listSpeechToTextProviderProfiles, listTextToSpeechProviderProfiles, @@ -40,6 +41,42 @@ import { type ModelProviderModelProfileV1 } from './app-settings' +describe('model provider retry settings', () => { + it('adds default retry settings to default providers', () => { + const settings = defaultModelProviderSettings() + + expect(settings.providers[0].retry).toEqual(defaultModelRequestRetrySettings()) + }) + + it('normalizes retry attempts, delay, and HTTP status codes', () => { + const settings = normalizeModelProviderSettings({ + providers: [ + { + id: 'custom', + name: 'Custom', + apiKey: 'k', + baseUrl: 'https://example.com/v1', + endpointFormat: 'chat_completions', + retry: { + maxAttempts: 99, + initialDelayMs: 700_000, + httpStatusCodes: [503, 429, 200, 503, 599] + }, + models: ['m'], + modelProfiles: {} + } + ] + }) + + const provider = settings.providers.find((item) => item.id === 'custom') + expect(provider?.retry).toEqual({ + maxAttempts: 10, + initialDelayMs: 600_000, + httpStatusCodes: [429, 503, 599] + }) + }) +}) + function settings(): AppSettingsV1 { return { version: 1, diff --git a/src/shared/app-settings-provider.ts b/src/shared/app-settings-provider.ts index c5f25f035..9d76508bd 100644 --- a/src/shared/app-settings-provider.ts +++ b/src/shared/app-settings-provider.ts @@ -4,6 +4,9 @@ import { DEFAULT_MUSIC_GENERATION_PROTOCOL, DEFAULT_MODEL_ENDPOINT_FORMAT, DEFAULT_MODEL_PROVIDER_ID, + DEFAULT_MODEL_REQUEST_RETRY_HTTP_STATUS_CODES, + DEFAULT_MODEL_REQUEST_RETRY_INITIAL_DELAY_MS, + DEFAULT_MODEL_REQUEST_RETRY_MAX_ATTEMPTS, NETWORK_PROXY_PROTOCOLS, DEFAULT_SPEECH_TO_TEXT_PROTOCOL, DEFAULT_TEXT_TO_SPEECH_PROTOCOL, @@ -36,6 +39,7 @@ import { type ModelProviderReasoningCapabilityV1, type ModelProviderProfilePatchV1, type ModelProviderProfileV1, + type ModelRequestRetrySettingsV1, type ModelProviderSettingsPatchV1, type ModelProviderSettingsV1, type NetworkProxySettingsV1, @@ -877,6 +881,7 @@ export function resolveKunRuntimeSettings(settings: AppSettingsV1): KunRuntimeSe ? normalizeDeepseekBaseUrl(runtimeBaseUrl) : normalizeDeepseekBaseUrl(providerBaseUrl), endpointFormat: provider.endpointFormat, + retry: provider.retry ?? defaultModelRequestRetrySettings(), imageGeneration: resolveKunImageGenerationSettings(settings), speechToText: resolveKunSpeechToTextSettings(settings), textToSpeech: resolveKunTextToSpeechSettings(settings), @@ -894,6 +899,7 @@ function defaultModelProviderProfile(apiKey: string, baseUrl: string): ModelProv apiKey: apiKey.trim(), baseUrl: normalizeModelProviderBaseUrl(baseUrl), endpointFormat: DEFAULT_MODEL_ENDPOINT_FORMAT, + retry: defaultModelRequestRetrySettings(), models: [...DEFAULT_COMPOSER_MODEL_IDS], modelProfiles: { 'deepseek-v4-pro': deepseekTextModelProfile(), @@ -929,6 +935,7 @@ function normalizeModelProviderProfile( apiKey: typeof input?.apiKey === 'string' ? input.apiKey.trim() : '', baseUrl, endpointFormat: normalizeModelEndpointFormat(input?.endpointFormat), + retry: normalizeModelRequestRetrySettings(input?.retry), ...(input?.kind === 'agent-sdk' ? { kind: 'agent-sdk' as const } : {}), models, modelProfiles, @@ -940,6 +947,42 @@ function normalizeModelProviderProfile( }) } +export function defaultModelRequestRetrySettings(): ModelRequestRetrySettingsV1 { + return { + maxAttempts: DEFAULT_MODEL_REQUEST_RETRY_MAX_ATTEMPTS, + initialDelayMs: DEFAULT_MODEL_REQUEST_RETRY_INITIAL_DELAY_MS, + httpStatusCodes: [...DEFAULT_MODEL_REQUEST_RETRY_HTTP_STATUS_CODES] + } +} + +export function normalizeModelRequestRetrySettings( + input: Partial | undefined +): ModelRequestRetrySettingsV1 { + const defaults = defaultModelRequestRetrySettings() + 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.MAX_SAFE_INTEGER): 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 deepseekTextModelProfile(): ModelProviderModelProfileV1 { return { ...DEFAULT_TEXT_MODEL_PROFILE, diff --git a/src/shared/app-settings-types.ts b/src/shared/app-settings-types.ts index 36bd2ce3c..255a5e486 100644 --- a/src/shared/app-settings-types.ts +++ b/src/shared/app-settings-types.ts @@ -141,6 +141,14 @@ export type NetworkProxySettingsV1 = { url: string } export type { ModelEndpointFormat } +export const DEFAULT_MODEL_REQUEST_RETRY_MAX_ATTEMPTS = 0 +export const DEFAULT_MODEL_REQUEST_RETRY_INITIAL_DELAY_MS = 3_000 +export const DEFAULT_MODEL_REQUEST_RETRY_HTTP_STATUS_CODES = [429, 503] as const +export type ModelRequestRetrySettingsV1 = { + maxAttempts: number + initialDelayMs: number + httpStatusCodes: number[] +} export const MODEL_PROVIDER_INPUT_MODALITIES = ['text', 'image'] as const export type ModelProviderInputModality = (typeof MODEL_PROVIDER_INPUT_MODALITIES)[number] export const MODEL_PROVIDER_MESSAGE_PARTS = ['text', 'image_url', 'input_image'] as const @@ -204,6 +212,8 @@ export type ModelProviderProfileV1 = { apiKey: string baseUrl: string endpointFormat: ModelEndpointFormat + /** 模型请求遇到临时失败或限流响应时使用的 HTTP 重试策略。 */ + retry?: ModelRequestRetrySettingsV1 /** * Transport kind. `agent-sdk` delegates whole turns to the embedded Claude * Agent SDK (Claude Pro/Max subscription); `apiKey` then carries the @@ -232,6 +242,7 @@ export type ModelProviderMusicCapabilityPatchV1 = Partial export type ModelProviderModelProfilePatchV1 = Partial export type ModelProviderProfilePatchV1 = Partial> & { + retry?: Partial modelProfiles?: Record image?: ModelProviderImageCapabilityPatchV1 | null speech?: ModelProviderSpeechCapabilityPatchV1 | null @@ -297,6 +308,8 @@ export type KunRuntimeSettingsV1 = { providerId: string /** Effective model request format. Resolved from the selected model provider. */ endpointFormat: ModelEndpointFormat + /** 当前生效的模型请求重试策略,由所选模型供应商解析得到。 */ + retry: ModelRequestRetrySettingsV1 runtimeToken: string dataDir: string model: string diff --git a/src/shared/model-provider-presets.ts b/src/shared/model-provider-presets.ts index 43e1fd99f..370afd100 100644 --- a/src/shared/model-provider-presets.ts +++ b/src/shared/model-provider-presets.ts @@ -14,6 +14,11 @@ import type { TextToSpeechProtocol, VideoGenerationProtocol } from './app-settings-types' +import { + DEFAULT_MODEL_REQUEST_RETRY_HTTP_STATUS_CODES, + DEFAULT_MODEL_REQUEST_RETRY_INITIAL_DELAY_MS, + DEFAULT_MODEL_REQUEST_RETRY_MAX_ATTEMPTS +} from './app-settings-types' export type ModelProviderPresetId = | 'litellm' @@ -693,6 +698,14 @@ export function getModelProviderPreset(id: string): ModelProviderPreset | null { return MODEL_PROVIDER_PRESETS.find((preset) => preset.id === id) ?? null } +function defaultPresetRetrySettings() { + return { + maxAttempts: DEFAULT_MODEL_REQUEST_RETRY_MAX_ATTEMPTS, + initialDelayMs: DEFAULT_MODEL_REQUEST_RETRY_INITIAL_DELAY_MS, + httpStatusCodes: [...DEFAULT_MODEL_REQUEST_RETRY_HTTP_STATUS_CODES] + } +} + export function modelProviderPresetProfile( preset: ModelProviderPreset, apiKey = '' @@ -703,6 +716,7 @@ export function modelProviderPresetProfile( apiKey: apiKey.trim(), baseUrl: preset.baseUrl, endpointFormat: preset.endpointFormat, + retry: defaultPresetRetrySettings(), ...(preset.kind ? { kind: preset.kind } : {}), models: [...preset.models], modelProfiles: copyModelProfiles(preset.modelProfiles), @@ -734,6 +748,7 @@ export function modelProviderTokenPlanProfile( apiKey: apiKey.trim(), baseUrl: resolvedBaseUrl, endpointFormat: tokenPlan.endpointFormat, + retry: defaultPresetRetrySettings(), models: [...tokenPlan.models], modelProfiles: copyModelProfiles(tokenPlan.modelProfiles), ...(tokenPlan.image From 07276a003138cefc26378e67aa88bac1d67df130 Mon Sep 17 00:00:00 2001 From: musnow Date: Sat, 4 Jul 2026 16:32:15 +0800 Subject: [PATCH 09/44] fix(sidebar): strengthen active pin action background Strengthen the active sidebar pin action background so it masks the active thread title more clearly.\n\nValidated with: npm run test -- SidebarProjectsSection.test.ts --- src/renderer/src/components/chat/SidebarProjectsSection.test.ts | 1 + src/renderer/src/components/sidebar/SidebarPrimitives.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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/sidebar/SidebarPrimitives.tsx b/src/renderer/src/components/sidebar/SidebarPrimitives.tsx index 68b860548..4aaf84189 100644 --- a/src/renderer/src/components/sidebar/SidebarPrimitives.tsx +++ b/src/renderer/src/components/sidebar/SidebarPrimitives.tsx @@ -216,7 +216,7 @@ export function SidebarIconButton({ }} className={cx( 'ds-no-drag inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-transparent text-[#9a9a9a] transition disabled:cursor-not-allowed disabled:opacity-40 dark:text-white/45', - active ? 'bg-[var(--ds-sidebar-row-active)] text-[#1f1f1f] shadow-[inset_0_0_0_1px_var(--ds-sidebar-row-ring)] dark:text-white' : toneClass, + active ? 'bg-[color-mix(in_srgb,var(--ds-sidebar-row-active)_72%,var(--ds-accent)_28%)] text-[#1f1f1f] shadow-[inset_0_0_0_1px_var(--ds-sidebar-row-ring)] dark:text-white' : toneClass, className )} title={title} From 166ef02c83ca6bf8cc558352e10151374e2f7496 Mon Sep 17 00:00:00 2001 From: musnow Date: Sat, 4 Jul 2026 16:32:45 +0800 Subject: [PATCH 10/44] feat(sidebar): archive workspace threads from context menu Add a workspace context-menu action to archive all active threads in the selected project.\n\nValidated with: npm run test -- SidebarProjectsSection.test.ts --- .../chat/SidebarProjectsSection.tsx | 64 ++++++++++++++++++- src/renderer/src/locales/en/common.json | 5 ++ src/renderer/src/locales/zh/common.json | 5 ++ 3 files changed, 73 insertions(+), 1 deletion(-) 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/locales/en/common.json b/src/renderer/src/locales/en/common.json index a543e5c32..080039d36 100644 --- a/src/renderer/src/locales/en/common.json +++ b/src/renderer/src/locales/en/common.json @@ -2042,6 +2042,11 @@ "sidebarWorkspaceNewThread": "New thread", "sidebarWorkspaceShowMore": "Show more ({{count}} more)", "sidebarWorkspaceShowLess": "Show less", + "sidebarWorkspaceArchiveThreads": "Archive all threads in this project", + "sidebarWorkspaceArchiveDialogTitle": "Archive threads in {{name}}?", + "sidebarWorkspaceArchiveDialogDescription": "This archives {{count}} active threads in this project.", + "sidebarWorkspaceArchiveDialogDetail": "Conversation history stays available and can be restored from archived threads later.", + "sidebarWorkspaceArchiveConfirmButton": "Archive all", "sidebarWorkspaceRemove": "Remove workspace", "sidebarWorkspaceRemoveConfirm": "Remove workspace \"{{path}}\" and all its threads? This cannot be undone.", "sidebarWorkspaceOpenInSystem": "Open in file explorer", diff --git a/src/renderer/src/locales/zh/common.json b/src/renderer/src/locales/zh/common.json index 640323b2a..d1bc9eb31 100644 --- a/src/renderer/src/locales/zh/common.json +++ b/src/renderer/src/locales/zh/common.json @@ -2042,6 +2042,11 @@ "sidebarWorkspaceNewThread": "新建会话", "sidebarWorkspaceShowMore": "展开显示(还有 {{count}} 条)", "sidebarWorkspaceShowLess": "收起", + "sidebarWorkspaceArchiveThreads": "归档当前项目中的所有会话", + "sidebarWorkspaceArchiveDialogTitle": "归档 {{name}} 中的会话?", + "sidebarWorkspaceArchiveDialogDescription": "这会归档该项目下 {{count}} 个活跃会话。", + "sidebarWorkspaceArchiveDialogDetail": "会话历史仍会保留,之后可以从归档会话里恢复。", + "sidebarWorkspaceArchiveConfirmButton": "全部归档", "sidebarWorkspaceRemove": "移除工作区", "sidebarWorkspaceRemoveConfirm": "确定移除工作区「{{path}}」及其中所有会话吗?此操作无法撤销。", "sidebarWorkspaceOpenInSystem": "在资源管理器中打开", From 1a6b76647cb28b291d193a48c96ea1e04e9e7e1e Mon Sep 17 00:00:00 2001 From: musnow Date: Sat, 4 Jul 2026 16:33:16 +0800 Subject: [PATCH 11/44] feat(workflow): move loop entry to top tabs Move the Loop entry into the workspace mode tabs and remove the duplicate sidebar command row.\n\nValidated with: npm run test -- WorkspaceModeTabs --- src/renderer/src/components/chat/Sidebar.tsx | 7 +---- .../src/components/chat/WorkspaceModeTabs.tsx | 31 ++++++++++++++----- .../chat/__tests__/WorkspaceModeTabs.test.ts | 27 ++++++++-------- .../src/components/design/DesignSidebar.tsx | 3 ++ .../workbench/WorkbenchLeftSidebar.tsx | 2 ++ .../src/components/write/WriteSidebar.tsx | 5 ++- src/renderer/src/locales/en/common.json | 2 +- src/renderer/src/locales/zh/common.json | 2 +- src/renderer/src/styles/base-shell.css | 16 ++++++++++ 9 files changed, 66 insertions(+), 29 deletions(-) diff --git a/src/renderer/src/components/chat/Sidebar.tsx b/src/renderer/src/components/chat/Sidebar.tsx index 67cac2b45..1fc06af9d 100644 --- a/src/renderer/src/components/chat/Sidebar.tsx +++ b/src/renderer/src/components/chat/Sidebar.tsx @@ -194,6 +194,7 @@ export function Sidebar({ @@ -238,12 +239,6 @@ export function Sidebar({ onClick={onScheduleOpen} active={activeView === 'schedule'} /> - } - label={t('workflow')} - onClick={onWorkflowOpen} - active={activeView === 'workflow'} - />
diff --git a/src/renderer/src/components/chat/WorkspaceModeTabs.tsx b/src/renderer/src/components/chat/WorkspaceModeTabs.tsx index 2b4b05b05..5cb330cb8 100644 --- a/src/renderer/src/components/chat/WorkspaceModeTabs.tsx +++ b/src/renderer/src/components/chat/WorkspaceModeTabs.tsx @@ -1,10 +1,11 @@ import type { ReactElement } from 'react' -import { Code2, Palette, PencilLine } from 'lucide-react' +import { Code2, Palette, PencilLine, Workflow } from 'lucide-react' import { useTranslation } from 'react-i18next' type Props = { activeView: 'chat' | 'write' | 'design' | 'claw' | 'schedule' | 'workflow' | 'subagents' onCodeOpen: () => void + onWorkflowOpen: () => void onWriteOpen: () => void onDesignOpen: () => void } @@ -12,13 +13,14 @@ type Props = { export function WorkspaceModeTabs({ activeView, onCodeOpen, + onWorkflowOpen, onWriteOpen, onDesignOpen }: Props): ReactElement { const { t } = useTranslation('common') const tabClass = (active: boolean): string => - `group inline-flex min-h-[28px] flex-1 min-w-0 items-center justify-center gap-1.5 rounded-[6px] px-2 py-0.5 text-[13px] outline-none transition-[background-color,color,box-shadow] duration-150 focus-visible:ring-2 focus-visible:ring-black/10 dark:focus-visible:ring-white/20 ${ + `workspace-mode-tab group inline-flex min-h-[28px] flex-1 min-w-0 items-center justify-center gap-1.5 rounded-[6px] px-2 py-0.5 text-[13px] outline-none transition-[background-color,color,box-shadow] duration-150 focus-visible:ring-2 focus-visible:ring-black/10 dark:focus-visible:ring-white/20 ${ active ? 'bg-white font-medium text-[#1f2733] shadow-[0_1px_2px_rgba(20,47,95,0.12),0_2px_5px_rgba(20,47,95,0.06)] dark:bg-white/[0.12] dark:text-white dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]' : 'font-normal text-[#646e7c] hover:text-[#1f2733] dark:text-white/55 dark:hover:text-white/90' @@ -34,8 +36,8 @@ export function WorkspaceModeTabs({ return (
+
) diff --git a/src/renderer/src/components/chat/__tests__/WorkspaceModeTabs.test.ts b/src/renderer/src/components/chat/__tests__/WorkspaceModeTabs.test.ts index ab763d7f8..13166449a 100644 --- a/src/renderer/src/components/chat/__tests__/WorkspaceModeTabs.test.ts +++ b/src/renderer/src/components/chat/__tests__/WorkspaceModeTabs.test.ts @@ -9,22 +9,24 @@ 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(), + onWorkflowOpen: vi.fn(), onWriteOpen: vi.fn(), onDesignOpen: vi.fn() } } - it('renders three tab buttons', () => { + it('renders four tab buttons', () => { const html = renderToStaticMarkup(createElement(WorkspaceModeTabs, props())) expect(html).toContain('Code') + expect(html).toContain('Loop') expect(html).toContain('Write') expect(html).toContain('Design') - expect(html.match(/role="tab"/g)?.length).toBe(3) + expect(html.match(/role="tab"/g)?.length).toBe(4) }) it('uses horizontal row layout not vertical column', () => { @@ -43,32 +45,31 @@ describe('WorkspaceModeTabs', () => { ) const flex1Matches = html.match(/flex-1/g) - expect(flex1Matches?.length).toBe(3) + expect(flex1Matches?.length).toBe(4) }) it('marks active button with aria-selected true', () => { - for (const activeView of ['chat', 'write', 'design'] as const) { + for (const activeView of ['chat', 'workflow', 'write', 'design'] as const) { const html = renderToStaticMarkup(createElement(WorkspaceModeTabs, props(activeView))) expect(html.match(/aria-selected="true"/g)?.length).toBe(1) - expect(html.match(/aria-selected="false"/g)?.length).toBe(2) + expect(html.match(/aria-selected="false"/g)?.length).toBe(3) } }) - it('preserves truncate class on button text for narrow sidebars', () => { + 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') }) @@ -78,7 +79,7 @@ describe('WorkspaceModeTabs', () => { ) expect(html).toContain('role="tablist"') - expect(html).toContain('Code / Write / Design') + expect(html).toContain('Code / Loop / Write / Design') }) it('does not render secondary switches in the sidebar mode tabs', () => { @@ -87,6 +88,6 @@ describe('WorkspaceModeTabs', () => { ) expect(html).not.toContain('role="switch"') - expect(html.match(/role="tab"/g)?.length).toBe(3) + expect(html.match(/role="tab"/g)?.length).toBe(4) }) }) diff --git a/src/renderer/src/components/design/DesignSidebar.tsx b/src/renderer/src/components/design/DesignSidebar.tsx index 11f3b5a56..5a258919a 100644 --- a/src/renderer/src/components/design/DesignSidebar.tsx +++ b/src/renderer/src/components/design/DesignSidebar.tsx @@ -39,6 +39,7 @@ import { CanvasLayersPanel } from './canvas/CanvasLayersPanel' type Props = { onCodeOpen: () => void + onWorkflowOpen: () => void onWriteOpen: () => void onDesignOpen: () => void onOpenSettings: (section?: SettingsRouteSection) => void @@ -64,6 +65,7 @@ export function getDesignSidebarDocumentLabel(doc: Pick): */ export function DesignSidebar({ onCodeOpen, + onWorkflowOpen, onWriteOpen, onDesignOpen, onOpenSettings, @@ -525,6 +527,7 @@ export function DesignSidebar({ diff --git a/src/renderer/src/components/workbench/WorkbenchLeftSidebar.tsx b/src/renderer/src/components/workbench/WorkbenchLeftSidebar.tsx index 0972d68e8..f22661b62 100644 --- a/src/renderer/src/components/workbench/WorkbenchLeftSidebar.tsx +++ b/src/renderer/src/components/workbench/WorkbenchLeftSidebar.tsx @@ -90,6 +90,7 @@ export function WorkbenchLeftSidebar({ {route === 'design' ? ( void + onWorkflowOpen: () => void onWriteOpen: () => void onDesignOpen: () => void onOpenSettings: (section?: SettingsRouteSection) => void @@ -59,6 +60,7 @@ export function WriteSidebar({ activeView, connectPhoneSidebarOpen, onCodeOpen, + onWorkflowOpen, onWriteOpen, onDesignOpen, onOpenSettings, @@ -281,6 +283,7 @@ export function WriteSidebar({ diff --git a/src/renderer/src/locales/en/common.json b/src/renderer/src/locales/en/common.json index 080039d36..bde0344a9 100644 --- a/src/renderer/src/locales/en/common.json +++ b/src/renderer/src/locales/en/common.json @@ -514,7 +514,7 @@ "plugins": "Plugins", "claw": "Connect phone", "schedule": "Scheduled tasks", - "workflow": "Create Loop", + "workflow": "Loop", "workflowSubtitle": "Wire up a node-based flow: run it manually, fire it on a schedule / webhook, or let the code-mode agent call it. Each flow has an \"Enabled\" and an \"AI-callable\" switch below it.", "workflowSidebarHint": "Open a Create Loop to edit its nodes on the canvas.", "workflowNew": "New Create Loop", diff --git a/src/renderer/src/locales/zh/common.json b/src/renderer/src/locales/zh/common.json index d1bc9eb31..08d6596c5 100644 --- a/src/renderer/src/locales/zh/common.json +++ b/src/renderer/src/locales/zh/common.json @@ -514,7 +514,7 @@ "plugins": "插件", "claw": "连接手机", "schedule": "定时任务", - "workflow": "创建loop", + "workflow": "Loop", "workflowSubtitle": "用节点搭好一条自动化流程:手动运行、按定时 / Webhook 自动触发,或让 code 模式的智能体直接调用。每条流程下方有「启用」和「AI 可调用」两个开关。", "workflowSidebarHint": "打开一个创建loop,在画布上编辑节点。", "workflowNew": "新建创建loop", diff --git a/src/renderer/src/styles/base-shell.css b/src/renderer/src/styles/base-shell.css index ab73cbba4..4a6959159 100644 --- a/src/renderer/src/styles/base-shell.css +++ b/src/renderer/src/styles/base-shell.css @@ -113,6 +113,22 @@ --ds-windows-titlebar-height: 40px; } +.workspace-mode-tabs { + container-type: inline-size; +} + +@container (max-width: 430px) { + .workspace-mode-tab { + gap: 0; + padding-left: 0.5rem; + padding-right: 0.5rem; + } + + .workspace-mode-tab-label { + display: none; + } +} + [data-theme='dark'] { /* 夜间深海:鲸的藏青描边作底色,小鸟浅蓝作高亮 */ --bg-app: #0f1422; From 8ec00c623e99a1ac4bca1b2d1202c711245c25d6 Mon Sep 17 00:00:00 2001 From: musnow Date: Sat, 4 Jul 2026 16:33:53 +0800 Subject: [PATCH 12/44] feat(im): add thread management commands Add Kun-managed IM commands for thread management, model switching, goal/status inspection, runtime usage, and IM attachment delivery. Also tighten IM model/provider selection and prompt-cache behavior.\n\nValidated with:\n- npm run test -- src/main/claw-runtime.test.ts src/shared/claw-commands.test.ts src/main/weixin-bridge-runtime.test.ts src/renderer/src/store/chat-store-thread-actions.test.ts\n- npm run test -- src/renderer/src/components/chat/FloatingComposer.test.ts src/renderer/src/components/chat/composer-model-selection.test.ts src/renderer/src/store/chat-store-app-actions.test.ts src/renderer/src/store/chat-store-helpers.test.ts src/shared/app-settings-provider.test.ts\n- npm --prefix kun run test -- src/adapters/tool/im-attachment-tool.test.ts src/adapters/tool/local-tool-host.test.ts\n- npm run typecheck\n- npm run build --- kun/src/adapters/tool/builtin-tool-types.ts | 4 +- kun/src/adapters/tool/builtin-tools.ts | 10 +- .../adapters/tool/im-attachment-tool.test.ts | 119 + kun/src/adapters/tool/im-attachment-tool.ts | 140 + kun/src/adapters/tool/local-tool-host.test.ts | 34 +- kun/src/adapters/tool/local-tool-host.ts | 3 - kun/src/contracts/turns.ts | 13 +- kun/src/domain/turn.ts | 2 + kun/src/loop/agent-loop.ts | 25 +- kun/src/ports/tool-host.ts | 2 + kun/src/services/review-service.ts | 2 +- kun/src/services/turn-service.ts | 1 + src/main/claw-runtime-helpers.ts | 10 +- src/main/claw-runtime.test.ts | 2487 +++++++++++++---- src/main/claw-runtime.ts | 1312 +++++++-- src/main/ipc/app-ipc-schemas.ts | 5 +- src/main/kun-process.ts | 4 - src/main/resolve-kun-binary.test.ts | 9 +- src/main/resolve-kun-binary.ts | 9 - src/main/telegram-runtime.ts | 46 +- src/main/weixin-bridge-runtime.test.ts | 38 +- src/main/weixin-bridge-runtime.ts | 33 +- src/renderer/src/components/Workbench.tsx | 1 + .../components/chat/FloatingComposer.test.ts | 11 + .../chat/FloatingComposerModelPicker.tsx | 20 +- src/renderer/src/components/chat/Sidebar.tsx | 15 +- .../chat/composer-model-selection.test.ts | 7 +- .../chat/composer-model-selection.ts | 2 - .../src/components/settings-section-claw.tsx | 23 + .../useWorkbenchChatComposerProps.ts | 4 +- .../useWorkbenchComposerSubmitController.ts | 97 +- .../workbench/useWorkbenchDesignRuntime.ts | 5 +- .../useWorkbenchNavigationController.ts | 18 +- .../useWorkbenchWriteAssistantRuntime.ts | 5 +- .../src/components/write/WriteSidebar.tsx | 27 +- src/renderer/src/locales/en/common.json | 16 +- src/renderer/src/locales/zh/common.json | 16 +- .../src/store/chat-store-app-actions.test.ts | 35 + .../src/store/chat-store-app-actions.ts | 9 +- .../src/store/chat-store-claw-actions.test.ts | 71 + .../src/store/chat-store-claw-actions.ts | 12 +- .../src/store/chat-store-helpers.test.ts | 14 + src/renderer/src/store/chat-store-helpers.ts | 21 +- .../store/chat-store-thread-action-helpers.ts | 9 + .../store/chat-store-thread-actions.test.ts | 56 +- .../src/store/chat-store-thread-actions.ts | 17 +- src/renderer/src/store/chat-store-types.ts | 2 +- src/shared/app-settings-claw.ts | 12 +- src/shared/app-settings-prompts.ts | 4 +- src/shared/app-settings-provider.test.ts | 3 +- src/shared/app-settings-types.ts | 6 + src/shared/app-settings.test.ts | 18 + src/shared/claw-commands.test.ts | 65 + src/shared/claw-commands.ts | 63 +- src/shared/model-provider-presets.ts | 4 - 55 files changed, 4178 insertions(+), 818 deletions(-) create mode 100644 kun/src/adapters/tool/im-attachment-tool.test.ts create mode 100644 kun/src/adapters/tool/im-attachment-tool.ts create mode 100644 src/shared/claw-commands.test.ts 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-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/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..5333bf498 --- /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 relativePath = relative(realRoot, realFile) + if (relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) { + 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, + 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/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/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/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 8f5179b40..dedcfbeb4 100644 --- a/kun/src/loop/agent-loop.ts +++ b/kun/src/loop/agent-loop.ts @@ -617,19 +617,6 @@ 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.' - ].join(' ') -} - function allowedToolNamesWithGuiStateTools( allowedToolNames: readonly string[] | undefined, activeGoal: boolean @@ -1443,9 +1430,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 +1441,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) }, @@ -1590,7 +1578,6 @@ export class AgentLoop { ...memoryInstructions(memories), ...(skillResolution.catalogInstruction ? [skillResolution.catalogInstruction] : []), ...skillResolution.instructions, - ...(userInputDisabled ? [userInputUnavailableInstruction()] : []), ...(toolPreferenceInstruction ? [toolPreferenceInstruction] : []), ...(effectiveToolSpecs.some((tool) => tool.name === 'bash') ? [shellRuntimeInstruction()] : []), ...(suggestVerification ? [verificationSuggestionInstruction()] : []), @@ -2123,6 +2110,7 @@ export class AgentLoop { activeSkillIds: skillResolution.activeSkillIds, allowedToolNames, userInputDisabled, + imContext: turn?.imContext === true, toolProviderKinds: new Map(tools.map((tool) => [tool.name, tool.providerKind])), approvalPolicy, sandboxMode, @@ -2145,6 +2133,7 @@ export class AgentLoop { activeSkillIds: readonly string[] allowedToolNames?: readonly string[] userInputDisabled?: boolean + imContext?: boolean toolProviderKinds: ReadonlyMap approvalPolicy: ToolHostContext['approvalPolicy'] sandboxMode: NonNullable @@ -2287,6 +2276,7 @@ export class AgentLoop { activeSkillIds: readonly string[] allowedToolNames?: readonly string[] userInputDisabled?: boolean + imContext?: boolean approvalPolicy: ToolHostContext['approvalPolicy'] sandboxMode: NonNullable signal: AbortSignal @@ -2298,6 +2288,7 @@ export class AgentLoop { threadMode: input.threadMode, ...(input.activePlanContext ? { guiPlan: input.activePlanContext } : {}), ...(input.guiDesignCanvas ? { guiDesignCanvas: true } : {}), + ...(input.imContext ? { imContext: true } : {}), model: input.modelCapabilities, activeSkillIds: input.activeSkillIds, memoryPolicy: { enabled: Boolean(this.opts.memoryStore) }, diff --git a/kun/src/ports/tool-host.ts b/kun/src/ports/tool-host.ts index 48f9d6690..2197c0fe1 100644 --- a/kun/src/ports/tool-host.ts +++ b/kun/src/ports/tool-host.ts @@ -66,6 +66,8 @@ 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 /** Skill ids activated for this turn, if the Skill runtime is enabled. */ 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/turn-service.ts b/kun/src/services/turn-service.ts index c54e1316a..fc7e9cd25 100644 --- a/kun/src/services/turn-service.ts +++ b/kun/src/services/turn-service.ts @@ -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({ diff --git a/src/main/claw-runtime-helpers.ts b/src/main/claw-runtime-helpers.ts index ee0f94f71..a7772af09 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 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..0a0a86975 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, @@ -110,6 +109,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 +194,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 +245,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 +275,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 +307,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 +1074,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 +1090,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 } @@ -889,11 +1374,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 +1409,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 +1608,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 +1927,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 +2044,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 +2087,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 +2125,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 +2138,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 +2389,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 +2410,7 @@ export class ClawRuntime { } return latestGeneratedFiles(JSON.parse(detailRes.body) as ThreadDetailJson, { workspaceRoot, + ...(turnId ? { turnId } : {}), maxFiles: 3 }) } catch (error) { @@ -1654,7 +2590,7 @@ export class ClawRuntime { } } - const commandReply = await this.handleIncomingImCommand(settings, { + const commandReply = await this.handleIncomingImCommandSafely(settings, { text, channel, conversation, @@ -1665,11 +2601,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 +2614,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 +2650,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 +2669,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 +2766,7 @@ export class ClawRuntime { } } - const commandReply = await this.handleIncomingImCommand(settings, { + const commandReply = await this.handleIncomingImCommandSafely(settings, { text: message.content, channel, conversation, @@ -1820,11 +2789,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 +2816,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 +2832,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 +3003,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 +3027,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 +3059,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 +3099,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 +3346,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 +3365,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 +3373,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 +3422,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 +3432,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 +3445,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 +3458,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 +3507,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/ipc/app-ipc-schemas.ts b/src/main/ipc/app-ipc-schemas.ts index 71d688ae3..fdde8fb33 100644 --- a/src/main/ipc/app-ipc-schemas.ts +++ b/src/main/ipc/app-ipc-schemas.ts @@ -695,7 +695,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({ @@ -748,6 +749,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/kun-process.ts b/src/main/kun-process.ts index c3762047d..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, diff --git a/src/main/resolve-kun-binary.test.ts b/src/main/resolve-kun-binary.test.ts index 87373aea9..8867071a1 100644 --- a/src/main/resolve-kun-binary.test.ts +++ b/src/main/resolve-kun-binary.test.ts @@ -100,9 +100,6 @@ describe('buildKunServeArgs', () => { 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/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/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..037675184 100644 --- a/src/renderer/src/components/chat/FloatingComposer.test.ts +++ b/src/renderer/src/components/chat/FloatingComposer.test.ts @@ -23,6 +23,7 @@ import { composerModelMenuItemSelected, composerMenuSupportsModel, composerReasoningEffortRequestValue, + buildComposerModelOptions, canSwitchComposerModelFromCurrent, filterComposerModelIds, normalizeComposerReasoningEffort @@ -408,6 +409,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/FloatingComposerModelPicker.tsx b/src/renderer/src/components/chat/FloatingComposerModelPicker.tsx index 0b1070b43..cdee5d366 100644 --- a/src/renderer/src/components/chat/FloatingComposerModelPicker.tsx +++ b/src/renderer/src/components/chat/FloatingComposerModelPicker.tsx @@ -123,16 +123,7 @@ export function FloatingComposerModelPicker({ const [modelFilter, setModelFilter] = useState('') const [menuPlacement, setMenuPlacement] = useState(null) const [submenuPlacement, setSubmenuPlacement] = useState(null) - const modelOptions = useMemo(() => { - const ordered = new Set() - for (const id of composerPickList) { - const normalized = id.trim() - if (normalized) ordered.add(normalized) - } - const current = composerModel.trim() - if (current) ordered.add(current) - return [...ordered] - }, [composerModel, composerPickList]) + const modelOptions = useMemo(() => buildComposerModelOptions(composerPickList), [composerPickList]) const providerMenuGroups = useMemo(() => { return buildComposerModelMenuGroups({ composerModelGroups, @@ -647,6 +638,15 @@ export function buildComposerModelMenuGroups({ return groups } +export function buildComposerModelOptions(composerPickList: readonly string[]): string[] { + const ordered = new Set() + for (const id of composerPickList) { + const normalized = id.trim() + if (normalized) ordered.add(normalized) + } + return [...ordered] +} + export function filterComposerModelIds( modelIds: readonly string[], query: string diff --git a/src/renderer/src/components/chat/Sidebar.tsx b/src/renderer/src/components/chat/Sidebar.tsx index 1fc06af9d..bee1f106a 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" - />
+ + + { - 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/settings-section-claw.tsx b/src/renderer/src/components/settings-section-claw.tsx index a26d1febe..307a6f668 100644 --- a/src/renderer/src/components/settings-section-claw.tsx +++ b/src/renderer/src/components/settings-section-claw.tsx @@ -339,6 +339,29 @@ export function ClawSettingsSection({ ctx }: { ctx: ClawSettingsContext }): Reac
} /> + + update({ + claw: { + im: { + recentThreadListLimit: Number(e.target.value) + } + } + }) + } + /> + } + /> {!hasTelegramChannel ? ( diff --git a/src/renderer/src/components/workbench/useWorkbenchChatComposerProps.ts b/src/renderer/src/components/workbench/useWorkbenchChatComposerProps.ts index 3424c267e..4997bf0bf 100644 --- a/src/renderer/src/components/workbench/useWorkbenchChatComposerProps.ts +++ b/src/renderer/src/components/workbench/useWorkbenchChatComposerProps.ts @@ -25,7 +25,7 @@ type UseWorkbenchChatComposerPropsInput = { composerReasoningEffort: ComposerProps['composerReasoningEffort'] setComposerReasoningEffort: ComposerProps['onComposerReasoningEffortChange'] lockVisionToTextModelSwitch: boolean - setClawChannelModel: (channelId: string, modelId: string) => void | Promise + setClawChannelModel: (channelId: string, modelId: string, providerId?: string) => void | Promise setComposerModel: (modelId: string, providerId?: string) => void openProvidersSettings: () => void handleSend: ComposerProps['onSend'] @@ -153,7 +153,7 @@ export function useWorkbenchChatComposerProps({ lockVisionToTextModelSwitch, onComposerModelChange: (modelId, providerId) => { if (route === 'claw' && activeClawChannelId) { - void setClawChannelModel(activeClawChannelId, modelId) + void setClawChannelModel(activeClawChannelId, modelId, providerId) return } setComposerModel(modelId, providerId) diff --git a/src/renderer/src/components/workbench/useWorkbenchComposerSubmitController.ts b/src/renderer/src/components/workbench/useWorkbenchComposerSubmitController.ts index 0ca01858b..de71b36eb 100644 --- a/src/renderer/src/components/workbench/useWorkbenchComposerSubmitController.ts +++ b/src/renderer/src/components/workbench/useWorkbenchComposerSubmitController.ts @@ -1,6 +1,10 @@ import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import type { ModelProviderModelGroup } from '@shared/kun-gui-api' +import { + isComposerChatModelId, + modelProfileSupportsTextChat +} from '@shared/app-settings' import type { AttachmentReference, NormalizedThread } from '../../agent/types' import type { ChatState, SendMessageOverrides } from '../../store/chat-store-types' import { useChatStore } from '../../store/chat-store' @@ -47,6 +51,7 @@ type PlanTurnOverrides = Pick< type UseWorkbenchComposerSubmitControllerParams = { activeClawChannelId: string activeClawChannelModel?: string + activeClawChannelProviderId?: string activeSddDraft: boolean activeThreadId: string | null attachmentUploadEnabled: boolean @@ -70,7 +75,7 @@ type UseWorkbenchComposerSubmitControllerParams = { sendPlanTurn: (text: string, overrides?: PlanTurnOverrides) => Promise sendSddAssistantPrompt: (value: string) => Promise setAttachmentUploadError: (message: string | null) => void - setClawChannelModel: (channelId: string, model: string) => Promise + setClawChannelModel: (channelId: string, model: string, providerId?: string) => Promise setError: (message: string | null) => void setInput: (value: string) => void threads: NormalizedThread[] @@ -79,6 +84,41 @@ type UseWorkbenchComposerSubmitControllerParams = { clearWriteQuotedSelections?: () => void } +type ClawComposerModelOption = { + providerId: string + model: string +} + +function listClawComposerModelOptions(groups: readonly ModelProviderModelGroup[]): ClawComposerModelOption[] { + const seen = new Set() + const options: ClawComposerModelOption[] = [] + for (const group of groups) { + const providerId = group.providerId.trim() + if (!providerId) continue + for (const modelId of group.modelIds) { + const model = modelId.trim() + if (!model || !isComposerChatModelId(model)) continue + if (!modelProfileSupportsTextChat(group.modelProfiles?.[model])) continue + const key = `${providerId}\u0000${model}` + if (seen.has(key)) continue + seen.add(key) + options.push({ providerId, model }) + } + } + return options +} + +function resolveClawComposerModelByIndex( + groups: readonly ModelProviderModelGroup[], + value: string +): ClawComposerModelOption | 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 listClawComposerModelOptions(groups)[index - 1] +} + export type WorkbenchComposerSubmitController = { handleSend: () => void sendWritePrompt: (value: string) => void @@ -87,6 +127,7 @@ export type WorkbenchComposerSubmitController = { export function useWorkbenchComposerSubmitController({ activeClawChannelId, activeClawChannelModel, + activeClawChannelProviderId, activeSddDraft, activeThreadId, attachmentUploadEnabled, @@ -140,12 +181,34 @@ export function useWorkbenchComposerSubmitController({ '', `- \`/help\`: ${t('clawHelpCommandHelp')}`, `- \`/new\`: ${t('clawHelpCommandNew')}`, - `- \`/model auto\`: ${t('clawHelpCommandModelAuto')}`, - `- \`/model pro\`: ${t('clawHelpCommandModelPro')}`, - `- \`/model flash\`: ${t('clawHelpCommandModelFlash')}`, - `- \`/model\`: ${t('clawHelpCommandModelShow')}` + `- \`/clear\`: ${t('clawHelpCommandClear')}`, + `- \`/list-model\`: ${t('clawHelpCommandModelList')}`, + `- \`/model \`: ${t('clawHelpCommandModelSwitch')}` ].join('\n'), [t]) + const clawModelListText = useCallback((): string => { + const options = listClawComposerModelOptions(composerModelGroups) + const currentProvider = activeClawChannelProviderId?.trim() ?? '' + const currentModel = activeClawChannelModel?.trim() || 'auto' + const rows = options.map((option, index) => { + const marker = option.providerId === currentProvider && option.model === currentModel ? '*' : '-' + return `${marker} ${index + 1}. \`${option.model}\` · provider \`${option.providerId}\`` + }) + return [ + t('clawModelCurrentWithProvider', { + provider: currentProvider || 'auto', + model: currentModel + }), + ...(rows.length > 0 + ? [ + t('clawModelAvailableList'), + ...rows, + t('clawModelSwitchHint') + ] + : [t('clawModelListEmpty')]) + ].join('\n') + }, [activeClawChannelModel, activeClawChannelProviderId, composerModelGroups, t]) + const readComposerFileContextEntries = useCallback(async ( references: ComposerFileReference[], workspace: string @@ -418,9 +481,19 @@ export function useWorkbenchComposerSubmitController({ return } setInput('') + const resolved = resolveClawComposerModelByIndex(composerModelGroups, command.model) + if (!resolved) { + const replyText = t('clawModelInvalidNumber', { value: command.model }) + appendLocalClawTurn(v, replyText) + void mirrorClawCommand(v, replyText) + return + } void (async () => { - await setClawChannelModel(activeClawChannelId, command.model) - const replyText = t('clawModelChanged', { model: command.model }) + await setClawChannelModel(activeClawChannelId, resolved.model, resolved.providerId) + const replyText = t('clawModelChangedWithProvider', { + model: resolved.model, + provider: resolved.providerId + }) appendLocalClawTurn(v, replyText) await mirrorClawCommand(v, replyText) })() @@ -432,17 +505,11 @@ export function useWorkbenchComposerSubmitController({ return } setInput('') - const replyText = t('clawModelCurrent', { - model: activeClawChannelModel ?? 'auto' - }) + const replyText = clawModelListText() appendLocalClawTurn(v, replyText) void mirrorClawCommand(v, replyText) return } - if (command?.kind === 'showProvider' || command?.kind === 'provider') { - setError('Provider commands are available in IM chats.') - return - } if (!activeClawChannelId) { setError(t('clawNoActiveIm')) return @@ -517,6 +584,7 @@ export function useWorkbenchComposerSubmitController({ }, [ activeClawChannelId, activeClawChannelModel, + activeClawChannelProviderId, activeSddDraft, activeThreadId, appendLocalClawTurn, @@ -525,6 +593,7 @@ export function useWorkbenchComposerSubmitController({ clawHelpText, clearComposerAttachments, clearComposerFileReferences, + clawModelListText, composerAttachments, composerFileReferences, composerMode, diff --git a/src/renderer/src/components/workbench/useWorkbenchDesignRuntime.ts b/src/renderer/src/components/workbench/useWorkbenchDesignRuntime.ts index a469b4374..d7b0b41b5 100644 --- a/src/renderer/src/components/workbench/useWorkbenchDesignRuntime.ts +++ b/src/renderer/src/components/workbench/useWorkbenchDesignRuntime.ts @@ -42,10 +42,9 @@ export function useWorkbenchDesignRuntime({ }) const designAssistantPickList = useMemo(() => { return buildComposerAssistantPickList({ - composerPickList, - currentModel: designAssistantModel + composerPickList }) - }, [composerPickList, designAssistantModel]) + }, [composerPickList]) const resolvedDesignAssistantProviderId = useMemo(() => { return resolveComposerAssistantProviderId({ composerModelGroups, diff --git a/src/renderer/src/components/workbench/useWorkbenchNavigationController.ts b/src/renderer/src/components/workbench/useWorkbenchNavigationController.ts index 1a1ea1d87..7a2f5a920 100644 --- a/src/renderer/src/components/workbench/useWorkbenchNavigationController.ts +++ b/src/renderer/src/components/workbench/useWorkbenchNavigationController.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, type Dispatch, type SetStateAction } from 'react' +import { useCallback, useEffect, useMemo, useRef, type Dispatch, type SetStateAction } from 'react' import type { WorkspaceFileTarget } from '@shared/workspace-file' import type { NormalizedThread, RuntimeConnectionStatus } from '../../agent/types' import { useChatStore } from '../../store/chat-store' @@ -110,6 +110,12 @@ export function useWorkbenchNavigationController({ setUseWorktreePool, setWriteAssistantOpen }: UseWorkbenchNavigationControllerParams): WorkbenchNavigationController { + const connectPhoneReturnRouteRef = useRef('chat') + + useEffect(() => { + if (route !== 'claw') connectPhoneReturnRouteRef.current = route + }, [route]) + const sidebarView: WorkbenchSidebarView = useMemo(() => { if (route === 'claw' || (route === 'plugins' && pluginHostRoute === 'claw')) return 'claw' if (route === 'schedule') return 'schedule' @@ -218,9 +224,15 @@ export function useWorkbenchNavigationController({ const toggleConnectPhone = useCallback((): void => { if (activeSddDraft) dismissActiveSddDraft({ closeAssistant: true }) + if (route === 'claw') { + setConnectPhoneSidebarOpen(false) + setRoute(connectPhoneReturnRouteRef.current === 'claw' ? 'chat' : connectPhoneReturnRouteRef.current) + return + } + connectPhoneReturnRouteRef.current = route openClaw() - setConnectPhoneSidebarOpen((open) => !open) - }, [activeSddDraft, dismissActiveSddDraft, openClaw, setConnectPhoneSidebarOpen]) + setConnectPhoneSidebarOpen(true) + }, [activeSddDraft, dismissActiveSddDraft, openClaw, route, setConnectPhoneSidebarOpen, setRoute]) const closeRightPanel = useCallback((): void => { if (route === 'write') { diff --git a/src/renderer/src/components/workbench/useWorkbenchWriteAssistantRuntime.ts b/src/renderer/src/components/workbench/useWorkbenchWriteAssistantRuntime.ts index 5efd07697..b3efee88e 100644 --- a/src/renderer/src/components/workbench/useWorkbenchWriteAssistantRuntime.ts +++ b/src/renderer/src/components/workbench/useWorkbenchWriteAssistantRuntime.ts @@ -22,10 +22,9 @@ export function useWorkbenchWriteAssistantRuntime({ const setWriteAssistantModel = useWriteWorkspaceStore((s) => s.setAssistantModel) const writeAssistantPickList = useMemo(() => { return buildComposerAssistantPickList({ - composerPickList, - currentModel: writeAssistantModel + composerPickList }) - }, [composerPickList, writeAssistantModel]) + }, [composerPickList]) const resolvedWriteAssistantProviderId = useMemo(() => { return resolveComposerAssistantProviderId({ composerModelGroups, diff --git a/src/renderer/src/components/write/WriteSidebar.tsx b/src/renderer/src/components/write/WriteSidebar.tsx index 68e009a1b..fa15a3852 100644 --- a/src/renderer/src/components/write/WriteSidebar.tsx +++ b/src/renderer/src/components/write/WriteSidebar.tsx @@ -262,20 +262,23 @@ export function WriteSidebar({ - } - label={t('claw')} +
+
+ } + label={t('settings')} + onClick={() => onOpenSettings('write')} + variant="footer" + /> +
+ - } - label={t('settings')} - onClick={() => onOpenSettings('write')} - variant="footer" - /> + > + +
} > diff --git a/src/renderer/src/locales/en/common.json b/src/renderer/src/locales/en/common.json index bde0344a9..ccb6603ed 100644 --- a/src/renderer/src/locales/en/common.json +++ b/src/renderer/src/locales/en/common.json @@ -1015,6 +1015,8 @@ "clawMasterSwitchHint": "Enabling starts scheduled tasks, the Feishu / Lark bridge, and the local IM webhook. Normal GUI chats are unaffected.", "clawDefaultWorkspace": "Default workspace", "clawDefaultWorkspaceHint": "From the GUI working directory", + "clawRecentThreadListLimit": "Recent conversations shown", + "clawRecentThreadListLimitDesc": "How many recent Kun conversations /list-threads returns in IM.", "clawPromptPrefix": "Phone connection prompt", "clawPromptPrefixHint": "Prepended to IM and scheduled task prompts", "clawPromptPrefixPlaceholder": "Example: You are my personal automation assistant. Before answering, identify the task source and current context.", @@ -1303,17 +1305,21 @@ "clawDeleteImConfirm": "Delete IM \"{{name}}\" and its local conversation configuration? This cannot be undone.", "clawSessionCleared": "Claw session cleared.", "clawModelChanged": "Claw model switched to {{model}}.", + "clawModelChangedWithProvider": "Claw model switched to {{model}} with provider {{provider}}.", "clawModelCurrent": "Current Claw model: `{{model}}`.", + "clawModelCurrentWithProvider": "Current provider: `{{provider}}`.\nCurrent model: `{{model}}`.", + "clawModelAvailableList": "Available models:", + "clawModelSwitchHint": "Switch model with `/model `.", + "clawModelInvalidNumber": "Invalid model number `{{value}}`. Send `/list-model` to see available numbers.", + "clawModelListEmpty": "No usable text models are available yet. Add models for a provider in Settings first.", "clawNoActiveIm": "Add or select a Feishu / Lark connection before sending a Claw message.", - "clawModelCommandHint": "Use /model auto, /model pro, or /model flash.", "clawNewSessionStarted": "Cleared the current Claw conversation for this Feishu / Lark connection.", "clawHelpTitle": "Available commands", "clawHelpCommandHelp": "Show this command help", "clawHelpCommandNew": "Clear the current conversation for the active Feishu / Lark connection", - "clawHelpCommandModelAuto": "Switch to the auto model", - "clawHelpCommandModelPro": "Switch to deepseek-v4-pro", - "clawHelpCommandModelFlash": "Switch to deepseek-v4-flash", - "clawHelpCommandModelShow": "Show the current model", + "clawHelpCommandClear": "Same as /new", + "clawHelpCommandModelList": "List available text models", + "clawHelpCommandModelSwitch": "Switch to a model listed by /list-model", "clawTimelineInbound": "From {{source}}", "clawTimelineSender": "Sender {{sender}}", "clawTimelineChatType": "{{chatType}} chat", diff --git a/src/renderer/src/locales/zh/common.json b/src/renderer/src/locales/zh/common.json index 08d6596c5..57e0e000c 100644 --- a/src/renderer/src/locales/zh/common.json +++ b/src/renderer/src/locales/zh/common.json @@ -1015,6 +1015,8 @@ "clawMasterSwitchHint": "开启后会运行定时任务、飞书 / Lark 桥接和本地 IM webhook;不会影响 GUI 内的普通聊天", "clawDefaultWorkspace": "默认工作区", "clawDefaultWorkspaceHint": "来自 GUI 当前工作目录", + "clawRecentThreadListLimit": "最近会话显示数量", + "clawRecentThreadListLimitDesc": "IM 里 /list-threads 返回的最近 Kun 会话数量。", "clawPromptPrefix": "手机连接提示", "clawPromptPrefixHint": "附加到 IM 和定时任务 prompt 前面", "clawPromptPrefixPlaceholder": "例如:你是我的个人自动化助手,回答前先确认任务来源和当前上下文。", @@ -1303,17 +1305,21 @@ "clawDeleteImConfirm": "确定删除 IM「{{name}}」及其本地会话配置吗?此操作无法撤销。", "clawSessionCleared": "Claw 会话已清空。", "clawModelChanged": "Claw 模型已切换到 {{model}}。", + "clawModelChangedWithProvider": "Claw 模型已切换到 {{model}},供应商为 {{provider}}。", "clawModelCurrent": "当前 Claw 模型是 `{{model}}`。", + "clawModelCurrentWithProvider": "当前供应商:`{{provider}}`。\n当前模型:`{{model}}`。", + "clawModelAvailableList": "可用模型:", + "clawModelSwitchHint": "切换模型:`/model <序号>`。", + "clawModelInvalidNumber": "无效的模型序号 `{{value}}`。发送 `/list-model` 查看可用序号。", + "clawModelListEmpty": "还没有可用的文本模型,请先在设置里为供应商配置模型。", "clawNoActiveIm": "请先添加或选择一个飞书 / Lark 连接,再发送 Claw 消息。", - "clawModelCommandHint": "可使用 /model auto、/model pro 或 /model flash。", "clawNewSessionStarted": "已清空当前飞书 / Lark 连接的 Claw 对话。", "clawHelpTitle": "可用命令", "clawHelpCommandHelp": "显示这份命令帮助", "clawHelpCommandNew": "清空当前飞书 / Lark 连接的对话", - "clawHelpCommandModelAuto": "切换到 auto 模型", - "clawHelpCommandModelPro": "切换到 deepseek-v4-pro", - "clawHelpCommandModelFlash": "切换到 deepseek-v4-flash", - "clawHelpCommandModelShow": "查看当前模型", + "clawHelpCommandClear": "等同于 /new", + "clawHelpCommandModelList": "列出可用文本模型", + "clawHelpCommandModelSwitch": "切换到 /list-model 列出的模型", "clawTimelineInbound": "来自 {{source}}", "clawTimelineSender": "发送者 {{sender}}", "clawTimelineChatType": "{{chatType}} 会话", diff --git a/src/renderer/src/store/chat-store-app-actions.test.ts b/src/renderer/src/store/chat-store-app-actions.test.ts index 2eaf89db4..595493098 100644 --- a/src/renderer/src/store/chat-store-app-actions.test.ts +++ b/src/renderer/src/store/chat-store-app-actions.test.ts @@ -283,6 +283,41 @@ describe('chat-store app actions composer model loading', () => { expect(state.composerProviderId).toBe('') }) + it('falls back to the first configured provider model when a thread selection was removed', async () => { + localStorage.setItem( + THREAD_COMPOSER_SELECTION_STORAGE_KEY, + JSON.stringify({ 'thread-a': { model: 'deleted-model', providerId: 'old-provider' } }) + ) + const { actions, state } = buildHarness({ + ok: true, + modelIds: ['MiniMax-M3', 'MiniMax-M2'], + defaultModelId: 'deepseek-v4-pro', + modelGroups: [{ + providerId: 'minimax', + label: 'MiniMax', + modelIds: ['MiniMax-M3', 'MiniMax-M2'] + }] + }) + state.activeThreadId = 'thread-a' + state.threads = [{ + id: 'thread-a', + title: 'Thread A', + workspace: '/tmp/project', + model: 'deleted-model', + status: 'idle', + mode: 'agent', + updatedAt: '2026-06-01T00:00:00.000Z' + }] + + await actions.loadComposerModels() + + expect(state.composerModel).toBe('MiniMax-M3') + expect(state.composerProviderId).toBe('minimax') + expect(JSON.parse(localStorage.getItem(THREAD_COMPOSER_SELECTION_STORAGE_KEY) ?? '{}')).toEqual({ + 'thread-a': { model: 'MiniMax-M3', providerId: 'minimax' } + }) + }) + it('blocks switching a chat with image attachments from vision to text-only', () => { const { actions, state } = buildHarness({ ok: true, diff --git a/src/renderer/src/store/chat-store-app-actions.ts b/src/renderer/src/store/chat-store-app-actions.ts index c91b70689..85cfda76a 100644 --- a/src/renderer/src/store/chat-store-app-actions.ts +++ b/src/renderer/src/store/chat-store-app-actions.ts @@ -1,5 +1,6 @@ import type i18next from 'i18next' import type { AppSettingsV1 } from '@shared/app-settings' +import type { ModelProviderModelGroup } from '@shared/kun-gui-api' import { rendererRuntimeClient } from '../agent/runtime-client' import type { ChatState, ChatStoreGet, ChatStoreSet, InitialSetupMode, PluginHostRoute, SettingsRouteSection } from './chat-store-types' import type { ComposerPlanMode } from './chat-store-helpers' @@ -28,7 +29,11 @@ type CreateAppActionsOptions = { rememberThreadComposerMode: (threadId: string, mode: ComposerPlanMode) => void readStoredComposerModel: (allowedIds: readonly string[]) => string mergeComposerPickList: (upstreamOk: boolean, upstreamIds: string[]) => string[] - fallbackComposerModel: (pickList: readonly string[], runtimeDefault: string) => string + fallbackComposerModel: ( + pickList: readonly string[], + runtimeDefault: string, + modelGroups?: readonly ModelProviderModelGroup[] + ) => string getComposerModelLoadPromise: () => Promise | null setComposerModelLoadPromise: (promise: Promise | null) => void applyTheme: (theme: AppSettingsV1['theme']) => void @@ -160,7 +165,7 @@ export function createAppActions(options: CreateAppActionsOptions): Pick< shouldPersist = false } if (model === '' || !isSelectable(model)) { - model = fallbackComposerModel(pick, runtimeDefault) + model = fallbackComposerModel(pick, runtimeDefault, groups) shouldPersist = false } if (shouldPersist) persistComposerModel(model) diff --git a/src/renderer/src/store/chat-store-claw-actions.test.ts b/src/renderer/src/store/chat-store-claw-actions.test.ts index 3c6dd0065..a8adefce8 100644 --- a/src/renderer/src/store/chat-store-claw-actions.test.ts +++ b/src/renderer/src/store/chat-store-claw-actions.test.ts @@ -214,4 +214,75 @@ describe('chat-store Claw actions helpers', () => { } }) }) + + it('saves the resolved provider and model when changing a Claw channel model', async () => { + rendererRuntimeClient.invalidateSettings() + let settings = { + workspaceRoot: '/Users/zxy/project', + claw: { + enabled: true, + channels: [channel({ + providerId: 'old-provider', + model: 'old-model' + })] + } + } + const kunGui = { + getSettings: vi.fn(async () => settings), + setSettings: vi.fn(async (patch: { claw?: { channels?: ClawImChannelV1[] } }) => { + settings = { + ...settings, + claw: { + ...settings.claw, + ...(patch.claw ?? {}), + channels: patch.claw?.channels ?? settings.claw.channels + } + } + return settings + }) + } + vi.stubGlobal('window', { kunGui }) + let state: Record = { + clawChannels: settings.claw.channels, + error: null + } + const set = vi.fn((partial: Record | ((current: typeof state) => Record)) => { + const patch = typeof partial === 'function' ? partial(state) : partial + state = { ...state, ...patch } + }) + const actions = createClawActions({ + set: set as never, + get: (() => state) as never, + i18n: { t: (key: string, values?: Record) => `${key}:${values?.model ?? ''}` }, + getProvider: vi.fn() as never, + newClawChannel: vi.fn() as never, + normalizeClawComposerModel: (raw: string) => raw.trim() || 'auto', + activeClawChannel: vi.fn() as never, + normalizeWorkspaceRoot: (workspaceRoot?: string | null) => workspaceRoot?.trim() ?? '', + formatRuntimeError: (error: unknown) => error instanceof Error ? error.message : String(error), + shouldOpenSettingsForError: () => false, + clearedThreadSelection: vi.fn() as never, + sseAbortRef: { current: null }, + clearBusyWatchdog: vi.fn() + }) + + await actions.setClawChannelModel('channel-1', 'MiniMax-M3', 'minimax') + + expect(kunGui.setSettings).toHaveBeenCalledWith({ + claw: { + channels: [expect.objectContaining({ + id: 'channel-1', + providerId: 'minimax', + model: 'MiniMax-M3' + })] + } + }) + expect(state.clawChannels).toEqual([ + expect.objectContaining({ + id: 'channel-1', + providerId: 'minimax', + model: 'MiniMax-M3' + }) + ]) + }) }) diff --git a/src/renderer/src/store/chat-store-claw-actions.ts b/src/renderer/src/store/chat-store-claw-actions.ts index fd452e8fc..c356d2399 100644 --- a/src/renderer/src/store/chat-store-claw-actions.ts +++ b/src/renderer/src/store/chat-store-claw-actions.ts @@ -604,13 +604,21 @@ export function createClawActions(options: CreateClawActionsOptions): Pick< } }, - setClawChannelModel: async (channelId, model) => { + setClawChannelModel: async (channelId, model, providerId) => { if (typeof window.kunGui === 'undefined') return const normalized = normalizeClawComposerModel(model) + const normalizedProviderId = providerId?.trim() ?? '' const settings = await rendererRuntimeClient.getSettings() const now = new Date().toISOString() const channels = settings.claw.channels.map((channel) => - channel.id === channelId ? { ...channel, model: normalized, updatedAt: now } : channel + channel.id === channelId + ? { + ...channel, + model: normalized, + ...(normalizedProviderId ? { providerId: normalizedProviderId } : {}), + updatedAt: now + } + : channel ) const saved = await rendererRuntimeClient.setSettings({ claw: { channels } }) emitRendererSettingsChanged(saved) diff --git a/src/renderer/src/store/chat-store-helpers.test.ts b/src/renderer/src/store/chat-store-helpers.test.ts index 9e73cd264..785e5c7a0 100644 --- a/src/renderer/src/store/chat-store-helpers.test.ts +++ b/src/renderer/src/store/chat-store-helpers.test.ts @@ -226,6 +226,20 @@ describe('chat-store Claw helpers', () => { expect(fallbackComposerModel([], '')).toBe('') }) + it('falls back to the first selectable provider model before built-in defaults', () => { + const groups: ModelProviderModelGroup[] = [{ + providerId: 'minimax', + label: 'MiniMax', + modelIds: ['MiniMax-M3', 'MiniMax-M2'] + }] + + expect(fallbackComposerModel( + ['MiniMax-M3', 'MiniMax-M2', 'deepseek-v4-pro'], + 'deepseek-v4-pro', + groups + )).toBe('MiniMax-M3') + }) + it('resolves context windows from the selected provider model profile', () => { const modelGroups: ModelProviderModelGroup[] = [ { diff --git a/src/renderer/src/store/chat-store-helpers.ts b/src/renderer/src/store/chat-store-helpers.ts index 8a2e3eadd..07167e190 100644 --- a/src/renderer/src/store/chat-store-helpers.ts +++ b/src/renderer/src/store/chat-store-helpers.ts @@ -419,13 +419,32 @@ export function mergeComposerPickList(upstreamOk: boolean, upstreamIds: string[] return [...ordered].sort((a, b) => a.localeCompare(b)) } -export function fallbackComposerModel(pickList: readonly string[], runtimeDefault: string): string { +export function fallbackComposerModel( + pickList: readonly string[], + runtimeDefault: string, + modelGroups: readonly ModelProviderModelGroup[] = [] +): string { + const firstProviderModel = firstSelectableProviderModel(pickList, modelGroups) + if (firstProviderModel) return firstProviderModel const allowed = new Set(pickList) const preferred = runtimeDefault.trim() if (preferred && preferred.toLowerCase() !== 'auto' && allowed.has(preferred)) return preferred return DEFAULT_COMPOSER_MODEL_IDS.find((id) => allowed.has(id)) ?? pickList[0] ?? '' } +function firstSelectableProviderModel( + pickList: readonly string[], + modelGroups: readonly ModelProviderModelGroup[] +): string { + for (const group of modelGroups) { + for (const modelId of group.modelIds) { + const model = modelId.trim() + if (model && composerModelSelectable(pickList, modelGroups, model)) return model + } + } + return '' +} + export function newClawChannel( provider: ClawImProvider, agentProfile?: Partial, diff --git a/src/renderer/src/store/chat-store-thread-action-helpers.ts b/src/renderer/src/store/chat-store-thread-action-helpers.ts index 230403e38..b0e0b81aa 100644 --- a/src/renderer/src/store/chat-store-thread-action-helpers.ts +++ b/src/renderer/src/store/chat-store-thread-action-helpers.ts @@ -11,6 +11,15 @@ export function fallbackComposerProviderIdForSend(state: ChatState): string { return state.route === 'claw' ? '' : state.composerProviderId.trim() } +export async function ensureRuntimeProviderForSend(input: { + providerId?: string + model?: string +}): Promise { + const providerId = input.providerId?.trim() + const model = input.model?.trim() + if (!providerId || !model || model.toLowerCase() === 'auto') return +} + export function composerSelectionForThread( state: ChatState, thread: Pick | null | undefined diff --git a/src/renderer/src/store/chat-store-thread-actions.test.ts b/src/renderer/src/store/chat-store-thread-actions.test.ts index 90f8bb833..c200341e1 100644 --- a/src/renderer/src/store/chat-store-thread-actions.test.ts +++ b/src/renderer/src/store/chat-store-thread-actions.test.ts @@ -35,6 +35,7 @@ function buildHarness(): { blocks: [], busy: true, clawChannels: [], + codeWorkspaceRoots: [], composerModel: '', composerProviderId: '', currentTurnId: null, @@ -153,7 +154,7 @@ describe('chat-store-thread-actions queued messages', () => { }) }) - it('sends the selected composer provider as a turn override without changing global settings', async () => { + it('sends the selected composer provider with the turn without switching the global runtime provider', async () => { const provider = { connect: vi.fn(async () => undefined), sendUserMessage: vi.fn(async () => ({ @@ -231,7 +232,7 @@ describe('chat-store-thread-actions queued messages', () => { ) }) - it('sends an override provider from the write route without changing global settings', async () => { + it('sends an override provider from the write route without switching the global runtime provider', async () => { const provider = { connect: vi.fn(async () => undefined), sendUserMessage: vi.fn(async () => ({ @@ -277,6 +278,57 @@ describe('chat-store-thread-actions queued messages', () => { expect.objectContaining({ model: 'MiniMax-M3', providerId: 'minimax-token-plan' }) ) }) + + it('snapshots the selected composer provider when creating the first thread', async () => { + const provider = { + connect: vi.fn(async () => undefined), + createThread: vi.fn(async () => ({ + id: 'thr_new', + title: 'hello', + updatedAt: '2026-06-09T00:00:00.000Z', + model: 'MiniMax-M3', + providerId: 'minimax-token-plan', + mode: 'agent', + workspace: '/workspace/deepseek-gui', + status: 'idle' + })), + sendUserMessage: vi.fn(async () => ({ + threadId: 'thr_new', + turnId: 'turn_1', + userMessageItemId: 'user_1' + })), + subscribeThreadEvents: vi.fn(async () => undefined) + } + registryMock.getProvider.mockReturnValue(provider) + vi.stubGlobal('window', { + kunGui: { + getSettings: vi.fn(async () => ({ + workspaceRoot: '/workspace/deepseek-gui', + agents: { kun: { providerId: 'deepseek', model: 'deepseek-v4-pro' } }, + codePromptPrefix: '' + })), + logError: vi.fn(async () => undefined) + } + }) + const { actions, state } = buildHarness() + state.activeThreadId = null + state.threads = [] + state.busy = false + state.composerModel = 'MiniMax-M3' + state.composerProviderId = 'minimax-token-plan' + + await expect(actions.sendMessage('hello', 'agent')).resolves.toBe(true) + + expect(provider.createThread).toHaveBeenCalledWith(expect.objectContaining({ + model: 'MiniMax-M3', + providerId: 'minimax-token-plan' + })) + expect(provider.sendUserMessage).toHaveBeenCalledWith( + 'thr_new', + 'hello', + expect.objectContaining({ model: 'MiniMax-M3', providerId: 'minimax-token-plan' }) + ) + }) }) describe('chat-store-thread-actions subscribeThreadEventsLive', () => { diff --git a/src/renderer/src/store/chat-store-thread-actions.ts b/src/renderer/src/store/chat-store-thread-actions.ts index d7a38a219..077000136 100644 --- a/src/renderer/src/store/chat-store-thread-actions.ts +++ b/src/renderer/src/store/chat-store-thread-actions.ts @@ -98,6 +98,7 @@ import { } from './chat-store-runtime' import { composerSelectionForThread, + ensureRuntimeProviderForSend, fallbackComposerProviderIdForSend, subscribeThreadEventsWithRecovery } from './chat-store-thread-action-helpers' @@ -793,6 +794,8 @@ export function createThreadActions( title: generatedTitle, // Provisional first-message title; let the backend LLM titler upgrade it. titleAuto: true, + ...(composerModel ? { model: composerModel } : {}), + ...(composerProviderId ? { providerId: composerProviderId } : {}), mode: mode ?? 'agent' }) : null @@ -852,6 +855,10 @@ export function createThreadActions( if (!channel && composerModel) { rememberThreadComposerSelection(activeThreadId, composerModel, composerProviderId) } + await ensureRuntimeProviderForSend({ + providerId: channel ? undefined : composerProviderId, + model: composerModel + }) const settings = await rendererRuntimeClient.getSettings() let workspaceCheckpointId: string | undefined const checkpointThread = get().threads.find((thread) => thread.id === activeThreadId) @@ -1060,6 +1067,8 @@ export function createThreadActions( set({ error: i18n.t('common:composerQueuePlaceholder') }) return false } + const composerModel = get().composerModel.trim() + const composerProviderId = get().composerProviderId.trim() let activeThreadId = get().activeThreadId try { if (!activeThreadId) { @@ -1082,6 +1091,8 @@ export function createThreadActions( ? await p.createThread({ workspace: workspaceRoot, title: i18n.t('common:slashCommandReviewTitle'), + ...(composerModel ? { model: composerModel } : {}), + ...(composerProviderId ? { providerId: composerProviderId } : {}), mode: 'agent' }) : null @@ -1099,8 +1110,6 @@ export function createThreadActions( })) } const threadSnap = get().threads.find((thread) => thread.id === activeThreadId) - const composerModel = get().composerModel.trim() - const composerProviderId = get().composerProviderId.trim() const userModelChip = optimisticUserModelLabel(composerModel, threadSnap?.model) const seqAtSend = get().lastSeq resetBusyRecoveryAttempts() @@ -1115,6 +1124,10 @@ export function createThreadActions( currentTurnId: null, currentTurnUserId: null }) + await ensureRuntimeProviderForSend({ + providerId: composerProviderId, + model: composerModel + }) const { turnId, userMessageItemId } = await p.reviewThread(activeThreadId, target, { ...(composerModel ? { model: composerModel } : {}), ...(composerProviderId ? { providerId: composerProviderId } : {}) diff --git a/src/renderer/src/store/chat-store-types.ts b/src/renderer/src/store/chat-store-types.ts index b9c1031e2..07ca67a67 100644 --- a/src/renderer/src/store/chat-store-types.ts +++ b/src/renderer/src/store/chat-store-types.ts @@ -257,7 +257,7 @@ export type ChatState = { selectClawConversation: (channelId: string, threadId: string) => Promise deleteClawChannel: (channelId: string) => Promise resetClawChannelSession: (channelId: string) => Promise - setClawChannelModel: (channelId: string, model: string) => Promise + setClawChannelModel: (channelId: string, model: string, providerId?: string) => Promise openInitialSetup: (mode?: InitialSetupMode) => void closeInitialSetup: () => void boot: () => Promise diff --git a/src/shared/app-settings-claw.ts b/src/shared/app-settings-claw.ts index 1caef0882..556ae1158 100644 --- a/src/shared/app-settings-claw.ts +++ b/src/shared/app-settings-claw.ts @@ -1,4 +1,5 @@ import { + DEFAULT_CLAW_RECENT_THREAD_LIST_LIMIT, DEFAULT_CLAW_MODEL, MIN_KUN_LOCAL_PORT, DEFAULT_WEIXIN_BRIDGE_RPC_URL, @@ -88,7 +89,8 @@ export function defaultClawSettings(): ClawSettingsV1 { providerId: '', model: DEFAULT_CLAW_MODEL, mode: 'agent', - responseTimeoutMs: 120_000 + responseTimeoutMs: 120_000, + recentThreadListLimit: DEFAULT_CLAW_RECENT_THREAD_LIST_LIMIT }, channels: [], tasks: [] @@ -136,7 +138,13 @@ export function normalizeClawSettings(input: ClawSettingsPatchV1 | undefined): C providerId: typeof im.providerId === 'string' ? im.providerId.trim() : '', model: typeof im.model === 'string' && im.model.trim() ? im.model.trim() : DEFAULT_CLAW_MODEL, mode: normalizeRunMode(im.mode), - responseTimeoutMs: normalizePositiveInteger(im.responseTimeoutMs, defaults.im.responseTimeoutMs, 5_000, 600_000) + responseTimeoutMs: normalizePositiveInteger(im.responseTimeoutMs, defaults.im.responseTimeoutMs, 5_000, 600_000), + recentThreadListLimit: normalizePositiveInteger( + im.recentThreadListLimit, + defaults.im.recentThreadListLimit, + 1, + 50 + ) }, channels: rawChannels .map((channel, index): ClawImChannelV1 => { diff --git a/src/shared/app-settings-prompts.ts b/src/shared/app-settings-prompts.ts index 02562a08e..cc04e11d1 100644 --- a/src/shared/app-settings-prompts.ts +++ b/src/shared/app-settings-prompts.ts @@ -138,7 +138,7 @@ export function normalizeClawImConversation(input: unknown): ClawImConversationV const directLocalThreadId = typeof raw.localThreadId === 'string' ? raw.localThreadId.trim() : '' const legacyAgentThreadId = readLegacyAgentThreadId(raw.agentThreadIds) const localThreadId = directLocalThreadId || legacyAgentThreadId - if (!id || !chatId || !latestMessageId || !localThreadId) return undefined + if (!id || !chatId || !latestMessageId) return undefined return { id, chatId, @@ -148,6 +148,8 @@ export function normalizeClawImConversation(input: unknown): ClawImConversationV senderName: typeof raw.senderName === 'string' ? raw.senderName.trim() : '', localThreadId, workspaceRoot: typeof raw.workspaceRoot === 'string' ? raw.workspaceRoot.trim() : '', + providerId: typeof raw.providerId === 'string' ? raw.providerId.trim() : '', + model: typeof raw.model === 'string' && raw.model.trim() ? raw.model.trim() : '', createdAt: typeof raw.createdAt === 'string' && raw.createdAt ? raw.createdAt : new Date().toISOString(), updatedAt: typeof raw.updatedAt === 'string' && raw.updatedAt ? raw.updatedAt : new Date().toISOString() } diff --git a/src/shared/app-settings-provider.test.ts b/src/shared/app-settings-provider.test.ts index da3591ee0..f9f2bea01 100644 --- a/src/shared/app-settings-provider.test.ts +++ b/src/shared/app-settings-provider.test.ts @@ -295,8 +295,7 @@ describe('model provider settings', () => { }) } }) - expect(xiaomi && modelProviderPresetProfile(xiaomi).models.slice(0, 3)).toEqual([ - 'mimo-v2.5-pro-ultraspeed', + expect(xiaomi && modelProviderPresetProfile(xiaomi).models.slice(0, 2)).toEqual([ 'mimo-v2.5-pro', 'mimo-v2.5' ]) diff --git a/src/shared/app-settings-types.ts b/src/shared/app-settings-types.ts index 255a5e486..47726791d 100644 --- a/src/shared/app-settings-types.ts +++ b/src/shared/app-settings-types.ts @@ -98,6 +98,7 @@ export type VideoGenerationProtocol = (typeof VIDEO_GENERATION_PROTOCOLS)[number export const DEFAULT_VIDEO_GENERATION_PROTOCOL: VideoGenerationProtocol = 'minimax-video' export const DEFAULT_CLAW_MODEL = 'auto' export const CLAW_MODEL_IDS = ['auto', 'deepseek-v4-pro', 'deepseek-v4-flash'] as const +export const DEFAULT_CLAW_RECENT_THREAD_LIST_LIMIT = 5 export const DEFAULT_SCHEDULE_MODEL = 'deepseek-v4-flash' export const SCHEDULE_MODEL_IDS = ['deepseek-v4-pro', 'deepseek-v4-flash'] as const export const DEFAULT_SCHEDULE_REASONING_EFFORT = 'medium' @@ -1461,6 +1462,7 @@ export type ClawImSettingsV1 = { model: string mode: ClawRunMode responseTimeoutMs: number + recentThreadListLimit: number } export type ClawTaskScheduleV1 = { @@ -1533,6 +1535,10 @@ export type ClawImConversationV1 = { /** Kun thread id this conversation maps to. */ localThreadId: string workspaceRoot: string + /** Model provider used by this IM conversation. Empty inherits channel/IM/global provider. */ + providerId?: string + /** Model used by this IM conversation. Empty inherits channel/IM model. */ + model?: string createdAt: string updatedAt: string } diff --git a/src/shared/app-settings.test.ts b/src/shared/app-settings.test.ts index 55170995c..1734f8557 100644 --- a/src/shared/app-settings.test.ts +++ b/src/shared/app-settings.test.ts @@ -434,6 +434,24 @@ describe('claw settings', () => { expect(normalized.claw.im.weixinBridgeUrl).toBe('http://127.0.0.1:18787/rpc') }) + it('normalizes the IM recent thread list limit', () => { + const defaults = defaultClawSettings() + expect(defaults.im.recentThreadListLimit).toBe(5) + + const normalized = normalizeAppSettings({ + ...settings(), + claw: { + ...defaults, + im: { + ...defaults.im, + recentThreadListLimit: 500 + } + } + }) + + expect(normalized.claw.im.recentThreadListLimit).toBe(50) + }) + it('migrates the legacy OpenClaw Gateway URL into the WeChat bridge URL', () => { const defaults = defaultClawSettings() const normalized = normalizeAppSettings({ diff --git a/src/shared/claw-commands.test.ts b/src/shared/claw-commands.test.ts new file mode 100644 index 000000000..3dbf04472 --- /dev/null +++ b/src/shared/claw-commands.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { parseClawCommand } from './claw-commands' + +describe('parseClawCommand', () => { + it('parses IM help and new-topic commands', () => { + expect(parseClawCommand('/help')).toEqual({ kind: 'help' }) + expect(parseClawCommand('/new')).toEqual({ kind: 'clear' }) + expect(parseClawCommand('/clear')).toEqual({ kind: 'clear' }) + expect(parseClawCommand('-clear')).toEqual({ kind: 'clear' }) + expect(parseClawCommand('/stop')).toEqual({ kind: 'stop' }) + expect(parseClawCommand('-stop')).toEqual({ kind: 'stop' }) + }) + + it('parses IM skill and goal commands', () => { + expect(parseClawCommand('/list-skills')).toEqual({ kind: 'showSkills' }) + expect(parseClawCommand('/list-mcp')).toEqual({ kind: 'showMcp' }) + expect(parseClawCommand('/list-goal')).toEqual({ kind: 'showGoal' }) + expect(parseClawCommand('/goal')).toEqual({ kind: 'invalidGoal' }) + expect(parseClawCommand('/goal ')).toEqual({ kind: 'invalidGoal' }) + expect(parseClawCommand('/goal 完成文档阅读')).toEqual({ + kind: 'setGoal', + objective: '完成文档阅读' + }) + }) + + it('parses IM thread list commands', () => { + expect(parseClawCommand('/list-threads')).toEqual({ kind: 'showThreads' }) + expect(parseClawCommand('-list-threads')).toEqual({ kind: 'showThreads' }) + }) + + it('parses IM current-thread commands', () => { + expect(parseClawCommand('/current')).toEqual({ kind: 'showCurrentThread' }) + expect(parseClawCommand('-current')).toEqual({ kind: 'showCurrentThread' }) + }) + + it('parses IM thread switch commands', () => { + expect(parseClawCommand('/switch 2')).toEqual({ kind: 'switchThread', target: '2' }) + expect(parseClawCommand('-switch 2')).toEqual({ kind: 'switchThread', target: '2' }) + }) + + it('parses IM model commands', () => { + expect(parseClawCommand('/list-model')).toEqual({ kind: 'showModel' }) + expect(parseClawCommand('/model')).toEqual({ kind: 'showModel' }) + expect(parseClawCommand('/model 3')).toEqual({ kind: 'model', model: '3' }) + }) + + it('parses IM workspace and usage commands', () => { + expect(parseClawCommand('/pwd')).toEqual({ kind: 'showWorkspace' }) + expect(parseClawCommand('/usage')).toEqual({ kind: 'showUsage' }) + }) + + it('leaves unknown slash commands for normal AI handling', () => { + expect(parseClawCommand('/wat')).toBeNull() + expect(parseClawCommand('/threads')).toBeNull() + expect(parseClawCommand('/list-models')).toBeNull() + expect(parseClawCommand('/停止')).toBeNull() + expect(parseClawCommand('任务列表')).toBeNull() + expect(parseClawCommand('当前会话')).toBeNull() + expect(parseClawCommand('切换到 文档阅读')).toBeNull() + expect(parseClawCommand('/provider')).toBeNull() + expect(parseClawCommand('/provider minimax')).toBeNull() + expect(parseClawCommand('/wat')).toBeNull() + expect(parseClawCommand('-wat')).toBeNull() + }) +}) diff --git a/src/shared/claw-commands.ts b/src/shared/claw-commands.ts index b425d5970..7538cea0e 100644 --- a/src/shared/claw-commands.ts +++ b/src/shared/claw-commands.ts @@ -1,27 +1,70 @@ export type ClawCommand = | { kind: 'clear' } | { kind: 'help' } + | { kind: 'showSkills' } + | { kind: 'showMcp' } + | { kind: 'showGoal' } + | { kind: 'showWorkspace' } + | { kind: 'showUsage' } + | { kind: 'invalidGoal' } + | { kind: 'setGoal'; objective: string } + | { kind: 'stop' } + | { kind: 'showThreads' } + | { kind: 'showCurrentThread' } + | { kind: 'switchThread'; target: string } | { kind: 'showModel' } | { kind: 'model'; model: string } - | { kind: 'showProvider' } - | { kind: 'provider'; providerId: string } export function parseClawCommand(text: string): ClawCommand | null { - const raw = text.trim().replace(/^//, '/') + const raw = text.trim() const lower = raw.toLowerCase() - if (/^[/-](?:clear|reset|new|清空|重置|新会话|新话题)$/.test(lower)) { + if (/^[/-](?:new|clear)$/.test(lower)) { return { kind: 'clear' } } - if (/^[/-](?:help|帮助|命令|\?)$/.test(lower)) { + if (/^[/-]stop$/.test(lower)) { + return { kind: 'stop' } + } + if (/^[/-]help$/.test(lower)) { return { kind: 'help' } } - const match = raw.match(/^[/-](?:model|模型)(?:\s+(.+))?$/i) + if (/^[/-]list-skills$/.test(lower)) { + return { kind: 'showSkills' } + } + if (/^[/-]list-mcp$/.test(lower)) { + return { kind: 'showMcp' } + } + if (/^[/-]list-goal$/.test(lower)) { + return { kind: 'showGoal' } + } + if (/^[/-]pwd$/.test(lower)) { + return { kind: 'showWorkspace' } + } + if (/^[/-]usage$/.test(lower)) { + return { kind: 'showUsage' } + } + const goalMatch = raw.match(/^[/-]goal(?:\s*(.*))?$/i) + if (goalMatch) { + const objective = (goalMatch[1] ?? '').trim() + return objective ? { kind: 'setGoal', objective } : { kind: 'invalidGoal' } + } + if (/^[/-]list-threads$/.test(lower)) { + return { kind: 'showThreads' } + } + if (/^[/-]current$/.test(lower)) { + return { kind: 'showCurrentThread' } + } + const switchMatch = raw.match(/^[/-]switch(?:\s+(.+))?$/i) + if (switchMatch) { + const target = (switchMatch[1] ?? '').trim() + return target ? { kind: 'switchThread', target } : { kind: 'showThreads' } + } + if (/^[/-]list-model$/.test(lower)) { + return { kind: 'showModel' } + } + const match = raw.match(/^[/-]model(?:\s+(.+))?$/i) if (match) { const value = (match[1] ?? '').trim() return value ? { kind: 'model', model: value } : { kind: 'showModel' } } - const providerMatch = raw.match(/^[/-](?:provider|供应商|提供商)(?:\s+(.+))?$/i) - if (!providerMatch) return null - const providerId = (providerMatch[1] ?? '').trim() - return providerId ? { kind: 'provider', providerId } : { kind: 'showProvider' } + return null } diff --git a/src/shared/model-provider-presets.ts b/src/shared/model-provider-presets.ts index 370afd100..0ac04b015 100644 --- a/src/shared/model-provider-presets.ts +++ b/src/shared/model-provider-presets.ts @@ -420,14 +420,12 @@ export const MODEL_PROVIDER_PRESETS: ModelProviderPreset[] = [ baseUrl: 'https://api.xiaomimimo.com/v1', endpointFormat: 'chat_completions', models: [ - 'mimo-v2.5-pro-ultraspeed', 'mimo-v2.5-pro', 'mimo-v2.5', 'mimo-v2-pro', 'mimo-v2-omni' ], modelProfiles: { - 'mimo-v2.5-pro-ultraspeed': xiaomiTextChatProfile(1_000_000), 'mimo-v2.5-pro': xiaomiTextChatProfile(1_000_000), 'mimo-v2.5': xiaomiVisionChatProfile(1_000_000), 'mimo-v2-pro': xiaomiTextChatProfile(1_000_000), @@ -452,14 +450,12 @@ export const MODEL_PROVIDER_PRESETS: ModelProviderPreset[] = [ ], endpointFormat: 'chat_completions', models: [ - 'mimo-v2.5-pro-ultraspeed', 'mimo-v2.5-pro', 'mimo-v2.5', 'mimo-v2-pro', 'mimo-v2-omni' ], modelProfiles: { - 'mimo-v2.5-pro-ultraspeed': xiaomiTextChatProfile(1_000_000), 'mimo-v2.5-pro': xiaomiTextChatProfile(1_000_000), 'mimo-v2.5': xiaomiVisionChatProfile(1_000_000), 'mimo-v2-pro': xiaomiTextChatProfile(1_000_000), From b60156eccd280a97c31ee6d453ae814447d028bd Mon Sep 17 00:00:00 2001 From: musnow Date: Sat, 4 Jul 2026 22:14:03 +0800 Subject: [PATCH 13/44] fix(subagent): handle detached interrupt state Summary:\n- Cascade parent interrupts to foreground subagents while keeping detached subagents independent.\n- Keep detached subagent lifecycle updates visible without restoring parent busy state.\n- Render detached completion callbacks as background subagent notices.\n\nTests:\n- npm run test\n- npm run typecheck\n- npm run build:kun\n- npm run lint --- .../adapters/tool/delegation-tool-provider.ts | 8 +- kun/src/contracts/events.ts | 1 + kun/src/contracts/items.ts | 2 +- kun/src/delegation/child-agent-executor.ts | 15 +- kun/src/delegation/delegation-runtime.test.ts | 214 ++++++++++++++++++ kun/src/delegation/delegation-runtime.ts | 105 ++++++++- kun/src/domain/item.ts | 4 +- kun/src/loop/steering-queue.ts | 4 +- kun/src/server/runtime-factory.ts | 5 + kun/src/services/turn-service.ts | 4 +- src/renderer/src/agent/kun-contract.ts | 2 + src/renderer/src/agent/kun-mapper.test.ts | 151 ++++++++++++ src/renderer/src/agent/kun-mapper.ts | 70 +++++- src/renderer/src/agent/types.ts | 14 +- .../src/components/chat/FloatingComposer.tsx | 10 +- .../src/components/chat/SubagentCallCard.tsx | 91 ++++++-- .../chat/message-timeline-bubbles.tsx | 83 ++++++- .../chat/message-timeline-process.tsx | 15 +- .../chat/message-timeline-turns.test.ts | 41 ++++ .../components/chat/message-timeline-turns.ts | 21 +- src/renderer/src/locales/en/common.json | 7 + src/renderer/src/locales/zh/common.json | 7 + .../store/chat-store-runtime-helpers.test.ts | 47 ++++ .../src/store/chat-store-runtime-helpers.ts | 19 +- .../src/store/chat-store-runtime.test.ts | 132 +++++++++++ src/renderer/src/store/chat-store-runtime.ts | 105 ++++++++- src/renderer/src/styles/base-shell.css | 60 ++++- src/shared/background-shell-notice.ts | 11 +- src/shared/background-subagent-notice.ts | 86 +++++++ 29 files changed, 1270 insertions(+), 64 deletions(-) create mode 100644 kun/src/delegation/delegation-runtime.test.ts create mode 100644 src/shared/background-subagent-notice.ts diff --git a/kun/src/adapters/tool/delegation-tool-provider.ts b/kun/src/adapters/tool/delegation-tool-provider.ts index f4ece7e7d..b8894e35c 100644 --- a/kun/src/adapters/tool/delegation-tool-provider.ts +++ b/kun/src/adapters/tool/delegation-tool-provider.ts @@ -79,7 +79,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 +94,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/contracts/events.ts b/kun/src/contracts/events.ts index 22dc7f5bd..24b466c88 100644 --- a/kun/src/contracts/events.ts +++ b/kun/src/contracts/events.ts @@ -78,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. 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/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..8b9117036 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, @@ -275,7 +291,9 @@ export class DelegationRuntime { tokenBudget, timeBudgetMs, returnFormat, + ...(input.detach ? { detached: true } : {}), status: 'queued', + childSeq: this.nextChildSeq(id), createdAt: queuedAt, updatedAt: queuedAt }) @@ -317,7 +335,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 +717,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 +736,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 +916,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/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/server/runtime-factory.ts b/kun/src/server/runtime-factory.ts index cd68195c7..d1970c5be 100644 --- a/kun/src/server/runtime-factory.ts +++ b/kun/src/server/runtime-factory.ts @@ -368,6 +368,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, @@ -583,6 +585,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, diff --git a/kun/src/services/turn-service.ts b/kun/src/services/turn-service.ts index fc7e9cd25..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' @@ -172,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/src/renderer/src/agent/kun-contract.ts b/src/renderer/src/agent/kun-contract.ts index c67bfe782..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' diff --git a/src/renderer/src/agent/kun-mapper.test.ts b/src/renderer/src/agent/kun-mapper.test.ts index 170049d0e..6f0c48f5d 100644 --- a/src/renderer/src/agent/kun-mapper.test.ts +++ b/src/renderer/src/agent/kun-mapper.test.ts @@ -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 @@ -961,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 093f82363..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, @@ -1236,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) @@ -1322,9 +1378,19 @@ export async function dispatchKunRuntimeEvent( 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/types.ts b/src/renderer/src/agent/types.ts index 43dc84410..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 diff --git a/src/renderer/src/components/chat/FloatingComposer.tsx b/src/renderer/src/components/chat/FloatingComposer.tsx index 957ddf2d2..0722cf5ee 100644 --- a/src/renderer/src/components/chat/FloatingComposer.tsx +++ b/src/renderer/src/components/chat/FloatingComposer.tsx @@ -2274,12 +2274,12 @@ export function FloatingComposer({
) : null}
{showToolbarStartControls ? ( -
+
{showComposerMenuButton ? ( <> + ) : null} {busy ? (