diff --git a/docs/issues/agent-loop-input-exec-responsiveness/plan.md b/docs/issues/agent-loop-input-exec-responsiveness/plan.md new file mode 100644 index 000000000..d3f9dfe05 --- /dev/null +++ b/docs/issues/agent-loop-input-exec-responsiveness/plan.md @@ -0,0 +1,39 @@ +# Agent Loop Input And Exec Responsiveness Plan + +## Runtime Input Flow + +- Keep `chat.steerActiveTurn` as the active-turn entry point. +- Remove hidden steer injection from provider request construction. +- Store active steer input as a priority pending row while the current loop turn continues, so steer + never aborts the in-flight provider request. +- At the process loop boundary after tool calls have returned, yield before continuing to the next + provider request when a pending steer exists; the outer runtime then drains steer through + `processMessage()` as a normal user message. +- Drain pending steer rows before pending queue rows by claiming the row and passing its payload to + `processMessage()` with visible user-message persistence. +- Keep steer rows locked and non-editable, but show not-yet-entered steer rows in the pending input + rail. + +## Exec Isolation + +- Keep the existing background exec core manager as the utility host implementation. +- Replace the exported singleton with a main-process RPC proxy that starts an Electron + `utilityProcess` from the existing main bundle using a dedicated host flag. +- Route `start`, `waitForCompletionOrYield`, `poll`, `log`, `write`, `kill`, `clear`, `remove`, + `cleanupConversation`, and `shutdown` through JSON-serializable messages. +- Track started sessions in the proxy so an unexpected utility exit can return diagnostic error + snapshots for affected sessions. + +## Compatibility + +- `PendingSessionInputMode` remains `queue | steer`. +- Existing `sessions.convertPendingInputToSteer` route remains available for stored and older UI + flows. +- `AgentBashHandler` keeps its current public return shape for completed and yielded commands. + +## Validation + +- Update agent runtime/session integration tests for visible steer turns. +- Update pending input rail tests to assert pending steer rows render as locked items. +- Preserve existing background exec core tests and add coverage around the utility proxy behavior + where practical. diff --git a/docs/issues/agent-loop-input-exec-responsiveness/spec.md b/docs/issues/agent-loop-input-exec-responsiveness/spec.md new file mode 100644 index 000000000..f06e27401 --- /dev/null +++ b/docs/issues/agent-loop-input-exec-responsiveness/spec.md @@ -0,0 +1,30 @@ +# Agent Loop Input And Exec Responsiveness + +## User Stories + +- As a user steering an active agent turn, I want my steering input to appear as a normal user + message so the conversation transcript matches what the agent saw. +- As a user running long shell commands, I want `exec` to yield quickly and keep DeepChat's main + process responsive while the command continues in a managed background session. + +## Acceptance Criteria + +- Active steer does not interrupt the current provider request; it records a priority steer input, + lets the current loop iteration finish including tool results, then yields before the next + provider loop so the steer payload is inserted as a normal visible user turn. +- Pending rows with `mode: "steer"` remain readable for compatibility, but drain before ordinary + queued rows as visible user turns instead of hidden request injections. +- Pending input UI shows not-yet-entered steer rows in the waiting lane as locked items, and keeps + ordinary queued follow-ups editable. +- Foreground `exec` returns a normal result if it finishes inside `yieldMs`; otherwise it returns a + running `sessionId`. +- Shell process spawning, output decoding, output offload, timeout, and process-tree termination + run in an Electron utility process rather than the main event loop. +- If the utility process exits unexpectedly, affected sessions surface an error snapshot instead of + blocking the main process. + +## Non-Goals + +- Do not change the public `exec` tool schema or permission semantics. +- Do not add renderer settings for exec isolation. +- Do not refactor the full agent runtime or provider loop. diff --git a/docs/issues/agent-loop-input-exec-responsiveness/tasks.md b/docs/issues/agent-loop-input-exec-responsiveness/tasks.md new file mode 100644 index 000000000..4a6b199b9 --- /dev/null +++ b/docs/issues/agent-loop-input-exec-responsiveness/tasks.md @@ -0,0 +1,12 @@ +# Tasks + +- [x] Add SDD artifacts for the combined responsiveness issue. +- [x] Queue active steer until the current loop iteration finishes without aborting the stream. +- [x] Yield the agent loop after completed tool calls when a pending steer should enter next. +- [x] Convert pending steer drain into visible user turns. +- [x] Remove hidden steer request injection. +- [x] Show not-yet-entered steer rows in the renderer pending rail. +- [x] Add utility-process RPC host for background exec. +- [x] Replace the production background exec singleton with a proxy. +- [x] Update and run targeted tests. +- [x] Run repository formatting, i18n, lint, and typecheck checks. diff --git a/docs/issues/telegram-message-markdown-render/plan.md b/docs/issues/telegram-message-markdown-render/plan.md index 72b6a1c83..e46ecc17c 100644 --- a/docs/issues/telegram-message-markdown-render/plan.md +++ b/docs/issues/telegram-message-markdown-render/plan.md @@ -5,6 +5,7 @@ - Add `src/main/presenter/remoteControlPresenter/telegram/telegramMarkdown.ts` exposing `convertMarkdownToTelegramHtml(text: string): string`, mirroring the Feishu-side `feishuMarkdown.ts` module location and shape. - The converter: - Escapes `&`, `<`, `>` first to make raw text safe for `parse_mode: 'HTML'`. + - Converts common GFM pipe tables into fenced fixed-width text before code-block extraction. - Handles fenced code blocks (` ``` `) by emitting `
...` and protecting the body from further Markdown processing.
- Handles inline code (` `…` `), bold (`**`/`__`), italic (`*`/`_`), strikethrough (`~~`), links, headings (`#…######`), unordered/ordered lists, and blockquotes (`>`).
- Auto-closes a dangling fenced block when called on a chunk that ends mid-block, so each chunk produces valid HTML for Telegram.
@@ -12,8 +13,9 @@
- In `TelegramPoller`:
- Convert chunk text via `convertMarkdownToTelegramHtml` before `sendMessage`/`editMessageText` calls in `syncDeliverySegment`, `sendChunkedMessage`, `dispatchOutboundActions`, and `editMessageText`. Pass `parseMode: 'HTML'`.
- Apply conversion to the interaction prompt text as well so callback prompts render formatting consistently.
+ - Retry the original plain-text chunk when Telegram returns a 400 entity-parse error for converted HTML.
## Validation
-- Run `pnpm test test/main/presenter/remoteControlPresenter/telegramClient.test.ts` (extended) and a new `telegramMarkdown.test.ts` covering core conversion rules and chunk-boundary behavior.
+- Run `pnpm test test/main/presenter/remoteControlPresenter/telegramClient.test.ts` (extended) and a new `telegramMarkdown.test.ts` covering core conversion rules, table fallback, and chunk-boundary behavior.
- Run `pnpm run typecheck:node` to confirm no signature break in callers (Poller, Adapter).
diff --git a/docs/issues/telegram-message-markdown-render/spec.md b/docs/issues/telegram-message-markdown-render/spec.md
index 6a3134103..eb840b7a0 100644
--- a/docs/issues/telegram-message-markdown-render/spec.md
+++ b/docs/issues/telegram-message-markdown-render/spec.md
@@ -8,8 +8,10 @@ When DeepChat's Telegram remote control bot delivers AI replies, command output,
- `telegramClient.sendMessage` and `telegramClient.editMessageText` call the Telegram Bot API with `parse_mode: 'HTML'` when the outbound text contains formatted content.
- AI answer / process delivery segments routed through `TelegramPoller.syncDeliverySegment` and outbound actions dispatched via `dispatchOutboundActions` go through a Markdown → Telegram-HTML converter that handles bold, italic, strikethrough, inline code, fenced code blocks, headings, links, ordered/unordered lists, blockquotes, and horizontal rules.
+- Common GFM pipe tables render as fixed-width preformatted text because Telegram does not support native table entities.
- Plain text (system replies, error messages, command echoes) is HTML-escaped and accepted by Telegram without parse-mode errors.
- Chunked streaming (4096 char limit) keeps each chunk independently renderable — partial Markdown left at a chunk boundary (e.g. an unclosed code fence) renders as text or a safely balanced block instead of breaking the Telegram parse.
+- If Telegram rejects converted HTML with an entity-parse error, DeepChat retries the same outbound chunk as plain text.
- Existing Telegram client tests pass; a new test covers the converter and parse-mode wiring.
## Constraints
diff --git a/docs/issues/telegram-message-markdown-render/tasks.md b/docs/issues/telegram-message-markdown-render/tasks.md
index 93b7e6940..d06dd3076 100644
--- a/docs/issues/telegram-message-markdown-render/tasks.md
+++ b/docs/issues/telegram-message-markdown-render/tasks.md
@@ -2,8 +2,8 @@
- [x] Capture the reproduction from issue #1665 and confirm `sendMessage`/`editMessageText` ship raw Markdown without `parse_mode`.
- [x] Draft SDD spec, plan, tasks documents.
-- [ ] Implement `telegram/telegramMarkdown.ts` with `convertMarkdownToTelegramHtml`.
-- [ ] Thread an optional `parseMode` through `TelegramClient.sendMessage`, `editMessageText`, and `sendPhoto`.
-- [ ] Update `TelegramPoller` to apply the converter and pass `parse_mode: 'HTML'` on all generated text paths.
-- [ ] Add focused tests for the converter and parse-mode wiring; keep existing telegram tests green.
+- [x] Implement `telegram/telegramMarkdown.ts` with `convertMarkdownToTelegramHtml`.
+- [x] Thread an optional `parseMode` through `TelegramClient.sendMessage`, `editMessageText`, and `sendPhoto`.
+- [x] Update `TelegramPoller` to apply the converter and pass `parse_mode: 'HTML'` on all generated text paths.
+- [x] Add focused tests for the converter, table fallback, parse-mode wiring, and plain-text retry.
- [ ] Run `pnpm run format`, `pnpm run lint`, `pnpm run typecheck:node`, and the focused test suites.
diff --git a/src/main/appMain.ts b/src/main/appMain.ts
new file mode 100644
index 000000000..1db239f86
--- /dev/null
+++ b/src/main/appMain.ts
@@ -0,0 +1,185 @@
+import { app, dialog } from 'electron'
+import { LifecycleManager, registerCoreHooks } from './presenter/lifecyclePresenter'
+import { getInstance, Presenter } from './presenter'
+import { electronApp } from '@electron-toolkit/utils'
+import log from 'electron-log'
+import { eventBus, SendTarget } from './eventbus'
+import { NOTIFICATION_EVENTS } from './events'
+import { registerWorkspacePreviewSchemes } from './presenter/workspacePresenter/workspacePreviewProtocol'
+import {
+ findDeepLinkArg,
+ findStartupDeepLink,
+ isDeepLinkUrl,
+ storeStartupDeepLink
+} from './lib/startupDeepLink'
+import { isInsecureTlsAllowed } from './lib/insecureTls'
+
+registerWorkspacePreviewSchemes()
+
+// Handle unhandled exceptions to prevent app crash or error dialogs
+process.on('uncaughtException', (error) => {
+ log.error('Uncaught Exception:', error)
+
+ const msg = error.message || 'Unknown error'
+ const isNetworkError = [
+ 'net::ERR',
+ 'ECONNRESET',
+ 'ETIMEDOUT',
+ 'ENOTFOUND',
+ 'Network Error',
+ 'fetch failed'
+ ].some((k) => msg.includes(k))
+
+ if (isNetworkError) {
+ // Send error to renderer to show a toast notification
+ // This is "elegant" and non-blocking
+ eventBus.sendToRenderer(NOTIFICATION_EVENTS.SHOW_ERROR, SendTarget.ALL_WINDOWS, {
+ id: Date.now().toString(),
+ title: 'Network Error',
+ message: msg,
+ type: 'error'
+ })
+ }
+})
+
+process.on('unhandledRejection', (reason) => {
+ log.error('Unhandled Rejection:', reason)
+})
+
+// Set application command line arguments
+app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required') // Allow video autoplay
+app.commandLine.appendSwitch('webrtc-max-cpu-consumption-percentage', '100') // Set WebRTC max CPU usage
+app.commandLine.appendSwitch('js-flags', '--max-old-space-size=4096') // Set V8 heap memory size
+if (isInsecureTlsAllowed()) {
+ // This disables certificate validation app-wide, so keep it limited to local debugging.
+ app.commandLine.appendSwitch('ignore-certificate-errors')
+}
+
+// Set platform-specific command line arguments
+if (process.platform == 'win32') {
+ // Windows platform specific parameters (currently commented out)
+ // app.commandLine.appendSwitch('in-process-gpu')
+ // app.commandLine.appendSwitch('wm-window-animations-disabled')
+}
+if (process.platform === 'darwin') {
+ // macOS platform specific parameters
+ app.commandLine.appendSwitch('disable-features', 'DesktopCaptureMacV2,IOSurfaceCapturer')
+}
+
+const gotSingleInstanceLock = app.requestSingleInstanceLock()
+if (!gotSingleInstanceLock) {
+ console.log('Another DeepChat instance is already running. Exiting current process.')
+ app.quit()
+}
+
+// Initialize presenter after ready
+let presenter: Presenter | undefined
+
+console.log('Main process starting, checking for deeplink...')
+console.log('Full command line arguments:', process.argv)
+const startupDeepLink = findStartupDeepLink(process.argv, process.env)
+if (startupDeepLink) {
+ console.log('Found startup deeplink during initialization:', startupDeepLink)
+ storeStartupDeepLink(startupDeepLink)
+} else {
+ console.log('No startup deeplink detected during initialization')
+}
+
+const focusExistingAppWindow = () => {
+ const targetWindow = presenter?.windowPresenter.getAllWindows()[0]
+ if (!targetWindow || targetWindow.isDestroyed()) {
+ return
+ }
+
+ if (targetWindow.isMinimized()) {
+ targetWindow.restore()
+ }
+ targetWindow.show()
+ targetWindow.focus()
+}
+
+const routeIncomingDeeplink = (url: string, source: string) => {
+ if (!isDeepLinkUrl(url)) {
+ return
+ }
+
+ console.log(`${source}:`, url)
+ const normalizedUrl = storeStartupDeepLink(url)
+ if (!normalizedUrl) {
+ return
+ }
+
+ if (presenter && app.isReady()) {
+ void presenter.deeplinkPresenter.handleDeepLink(normalizedUrl)
+ }
+}
+
+// Listen for open-url events that might occur during startup
+// This must be set before app.whenReady() because open-url events can fire before that
+app.on('open-url', (event, url) => {
+ event.preventDefault()
+ routeIncomingDeeplink(url, 'Received open-url event')
+})
+
+// Also listen for second-instance events (Windows/Linux)
+if (gotSingleInstanceLock) {
+ app.on('second-instance', (_event, commandLine) => {
+ console.log('Received second-instance event with command line:', commandLine)
+ focusExistingAppWindow()
+
+ const deepLinkUrl = findDeepLinkArg(commandLine)
+ if (deepLinkUrl) {
+ routeIncomingDeeplink(deepLinkUrl, 'Received second-instance deeplink')
+ }
+ })
+}
+
+// Initialize lifecycle manager and register core hooks
+const lifecycleManager = new LifecycleManager()
+registerCoreHooks(lifecycleManager)
+
+function clearPresenterPermissionCaches(activePresenter?: Presenter): void {
+ if (!activePresenter) return
+
+ activePresenter.commandPermissionService.clearAll()
+ activePresenter.filePermissionService.clearAll()
+ activePresenter.settingsPermissionService.clearAll()
+}
+
+// Start the lifecycle management system instead of using app.whenReady()
+app.whenReady().then(async () => {
+ // Set app user model id for windows
+ electronApp.setAppUserModelId('com.wefonk.deepchat')
+ try {
+ console.log('main: Application lifecycle startup')
+ await lifecycleManager.start()
+ presenter = getInstance(lifecycleManager)
+ console.log('main: Application lifecycle startup completed successfully')
+ } catch (error) {
+ console.error('main: Application lifecycle startup failed:', error)
+ dialog.showErrorBox(
+ 'Application startup failed',
+ error instanceof Error ? error.message : String(error)
+ )
+ app.quit() // Serious error, exit the program
+ }
+})
+
+app.on('before-quit', () => {
+ clearPresenterPermissionCaches(presenter)
+})
+
+// Handle window-all-closed event
+app.on('window-all-closed', () => {
+ clearPresenterPermissionCaches(presenter)
+ if (!presenter) return
+
+ // Check if there are any non-floating-button windows
+ const mainWindows = presenter.windowPresenter.getAllWindows()
+
+ if (mainWindows.length === 0) {
+ // When only floating button windows exist, quit app on non-macOS platforms
+ console.log('main: All main windows closed, requesting shutdown')
+ app.quit() // Keep this event to avoid unexpected situations
+ }
+})
diff --git a/src/main/index.ts b/src/main/index.ts
index 77c0a4fe3..e45139758 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -1,181 +1,5 @@
-import { app, dialog } from 'electron'
-import { LifecycleManager, registerCoreHooks } from './presenter/lifecyclePresenter'
-import { getInstance, Presenter } from './presenter'
-import { electronApp } from '@electron-toolkit/utils'
-import log from 'electron-log'
-import { eventBus, SendTarget } from './eventbus'
-import { NOTIFICATION_EVENTS } from './events'
-import { registerWorkspacePreviewSchemes } from './presenter/workspacePresenter/workspacePreviewProtocol'
-import {
- findDeepLinkArg,
- findStartupDeepLink,
- isDeepLinkUrl,
- storeStartupDeepLink
-} from './lib/startupDeepLink'
+import { runBackgroundExecUtilityHostIfRequested } from './lib/agentRuntime/backgroundExecUtilityHost'
-registerWorkspacePreviewSchemes()
-
-// Handle unhandled exceptions to prevent app crash or error dialogs
-process.on('uncaughtException', (error) => {
- log.error('Uncaught Exception:', error)
-
- const msg = error.message || 'Unknown error'
- const isNetworkError = [
- 'net::ERR',
- 'ECONNRESET',
- 'ETIMEDOUT',
- 'ENOTFOUND',
- 'Network Error',
- 'fetch failed'
- ].some((k) => msg.includes(k))
-
- if (isNetworkError) {
- // Send error to renderer to show a toast notification
- // This is "elegant" and non-blocking
- eventBus.sendToRenderer(NOTIFICATION_EVENTS.SHOW_ERROR, SendTarget.ALL_WINDOWS, {
- id: Date.now().toString(),
- title: 'Network Error',
- message: msg,
- type: 'error'
- })
- }
-})
-
-process.on('unhandledRejection', (reason) => {
- log.error('Unhandled Rejection:', reason)
-})
-
-// Set application command line arguments
-app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required') // Allow video autoplay
-app.commandLine.appendSwitch('webrtc-max-cpu-consumption-percentage', '100') // Set WebRTC max CPU usage
-app.commandLine.appendSwitch('js-flags', '--max-old-space-size=4096') // Set V8 heap memory size
-app.commandLine.appendSwitch('ignore-certificate-errors') // Ignore certificate errors (for dev or specific scenarios)
-
-// Set platform-specific command line arguments
-if (process.platform == 'win32') {
- // Windows platform specific parameters (currently commented out)
- // app.commandLine.appendSwitch('in-process-gpu')
- // app.commandLine.appendSwitch('wm-window-animations-disabled')
-}
-if (process.platform === 'darwin') {
- // macOS platform specific parameters
- app.commandLine.appendSwitch('disable-features', 'DesktopCaptureMacV2,IOSurfaceCapturer')
-}
-
-const gotSingleInstanceLock = app.requestSingleInstanceLock()
-if (!gotSingleInstanceLock) {
- console.log('Another DeepChat instance is already running. Exiting current process.')
- app.quit()
-}
-
-// Initialize presenter after ready
-let presenter: Presenter | undefined
-
-console.log('Main process starting, checking for deeplink...')
-console.log('Full command line arguments:', process.argv)
-const startupDeepLink = findStartupDeepLink(process.argv, process.env)
-if (startupDeepLink) {
- console.log('Found startup deeplink during initialization:', startupDeepLink)
- storeStartupDeepLink(startupDeepLink)
-} else {
- console.log('No startup deeplink detected during initialization')
-}
-
-const focusExistingAppWindow = () => {
- const targetWindow = presenter?.windowPresenter.getAllWindows()[0]
- if (!targetWindow || targetWindow.isDestroyed()) {
- return
- }
-
- if (targetWindow.isMinimized()) {
- targetWindow.restore()
- }
- targetWindow.show()
- targetWindow.focus()
-}
-
-const routeIncomingDeeplink = (url: string, source: string) => {
- if (!isDeepLinkUrl(url)) {
- return
- }
-
- console.log(`${source}:`, url)
- const normalizedUrl = storeStartupDeepLink(url)
- if (!normalizedUrl) {
- return
- }
-
- if (presenter && app.isReady()) {
- void presenter.deeplinkPresenter.handleDeepLink(normalizedUrl)
- }
-}
-
-// Listen for open-url events that might occur during startup
-// This must be set before app.whenReady() because open-url events can fire before that
-app.on('open-url', (event, url) => {
- event.preventDefault()
- routeIncomingDeeplink(url, 'Received open-url event')
-})
-
-// Also listen for second-instance events (Windows/Linux)
-if (gotSingleInstanceLock) {
- app.on('second-instance', (_event, commandLine) => {
- console.log('Received second-instance event with command line:', commandLine)
- focusExistingAppWindow()
-
- const deepLinkUrl = findDeepLinkArg(commandLine)
- if (deepLinkUrl) {
- routeIncomingDeeplink(deepLinkUrl, 'Received second-instance deeplink')
- }
- })
-}
-
-// Initialize lifecycle manager and register core hooks
-const lifecycleManager = new LifecycleManager()
-registerCoreHooks(lifecycleManager)
-
-function clearPresenterPermissionCaches(activePresenter?: Presenter): void {
- if (!activePresenter) return
-
- activePresenter.commandPermissionService.clearAll()
- activePresenter.filePermissionService.clearAll()
- activePresenter.settingsPermissionService.clearAll()
+if (!runBackgroundExecUtilityHostIfRequested()) {
+ void import('./appMain')
}
-
-// Start the lifecycle management system instead of using app.whenReady()
-app.whenReady().then(async () => {
- // Set app user model id for windows
- electronApp.setAppUserModelId('com.wefonk.deepchat')
- try {
- console.log('main: Application lifecycle startup')
- await lifecycleManager.start()
- presenter = getInstance(lifecycleManager)
- console.log('main: Application lifecycle startup completed successfully')
- } catch (error) {
- console.error('main: Application lifecycle startup failed:', error)
- dialog.showErrorBox(
- 'Application startup failed',
- error instanceof Error ? error.message : String(error)
- )
- app.quit() // Serious error, exit the program
- }
-})
-
-app.on('before-quit', () => {
- clearPresenterPermissionCaches(presenter)
-})
-
-// Handle window-all-closed event
-app.on('window-all-closed', () => {
- clearPresenterPermissionCaches(presenter)
- if (!presenter) return
-
- // Check if there are any non-floating-button windows
- const mainWindows = presenter.windowPresenter.getAllWindows()
-
- if (mainWindows.length === 0) {
- // When only floating button windows exist, quit app on non-macOS platforms
- console.log('main: All main windows closed, requesting shutdown')
- app.quit() // Keep this event to avoid unexpected situations
- }
-})
diff --git a/src/main/lib/agentRuntime/backgroundExecSessionManager.ts b/src/main/lib/agentRuntime/backgroundExecSessionManager.ts
index 2e8ccafdb..2967f0d3d 100644
--- a/src/main/lib/agentRuntime/backgroundExecSessionManager.ts
+++ b/src/main/lib/agentRuntime/backgroundExecSessionManager.ts
@@ -1,6 +1,8 @@
import { spawn, type ChildProcess } from 'child_process'
import fs from 'fs'
import path from 'path'
+import { fileURLToPath } from 'url'
+import type { UtilityProcess } from 'electron'
import { nanoid } from 'nanoid'
import logger from '@shared/logger'
import { getUserShell } from './shellEnvHelper'
@@ -108,6 +110,52 @@ interface LogResult {
timedOut?: boolean
}
+export type BackgroundExecRpcMethod =
+ | 'start'
+ | 'list'
+ | 'poll'
+ | 'log'
+ | 'waitForCompletionOrYield'
+ | 'getCompletionResult'
+ | 'write'
+ | 'kill'
+ | 'clear'
+ | 'remove'
+ | 'cleanupConversation'
+ | 'shutdown'
+
+export interface BackgroundExecRpcRequest {
+ type: 'background-exec:request'
+ id: string
+ method: BackgroundExecRpcMethod
+ args: unknown[]
+}
+
+export type BackgroundExecRpcResponse =
+ | {
+ type: 'background-exec:response'
+ id: string
+ ok: true
+ data: unknown
+ }
+ | {
+ type: 'background-exec:response'
+ id: string
+ ok: false
+ error: {
+ message: string
+ stack?: string
+ }
+ }
+
+interface TrackedSessionMeta {
+ conversationId: string
+ sessionId: string
+ command: string
+ createdAt: number
+ lastAccessedAt: number
+}
+
export class BackgroundExecSessionManager {
private sessions = new Map` text * - Blockquote lines `> ` -> grouped into `...` * - Horizontal rules `---` / `***` -> `———` * @@ -52,6 +53,108 @@ const renderCodeBlock = (lang: string, body: string): string => { const renderInlineCode = (body: string): string => `${escapeHtml(body)}` +const parseMarkdownTableRow = (line: string): string[] | null => { + const trimmed = line.trim() + if (!trimmed.includes('|')) { + return null + } + + const withoutOuterPipes = + trimmed.startsWith('|') && trimmed.endsWith('|') ? trimmed.slice(1, -1) : trimmed + const cells = withoutOuterPipes.split('|').map((cell) => cell.trim()) + + return cells.length >= 2 ? cells : null +} + +const isMarkdownTableSeparator = (cells: string[]): boolean => + cells.length >= 2 && + cells.every((cell) => { + const normalized = cell.replace(/\s/g, '') + return /^:?-{3,}:?$/.test(normalized) + }) + +const getCellWidth = (cell: string): number => Array.from(cell).length + +const padCell = (cell: string, width: number): string => + `${cell}${' '.repeat(Math.max(0, width - getCellWidth(cell)))}` + +const formatMarkdownTableAsText = (rows: string[][]): string => { + const columnCount = rows.reduce((max, row) => Math.max(max, row.length), 0) + const normalizedRows = rows.map((row) => + Array.from({ length: columnCount }, (_, index) => row[index] ?? '') + ) + const widths = Array.from({ length: columnCount }, (_, index) => + Math.max(2, ...normalizedRows.map((row) => getCellWidth(row[index] ?? ''))) + ) + + const formatRow = (row: string[]): string => + row + .map((cell, index) => padCell(cell, widths[index] ?? 2)) + .join(' | ') + .trimEnd() + const separator = widths.map((width) => '-'.repeat(width)).join('-|-') + + return [formatRow(normalizedRows[0] ?? []), separator, ...normalizedRows.slice(1).map(formatRow)] + .filter(Boolean) + .join('\n') +} + +const convertMarkdownTablesToCodeBlocks = (text: string): string => { + const lines = text.split('\n') + const output: string[] = [] + let index = 0 + let fenceMarker: string | null = null + + while (index < lines.length) { + const line = lines[index] ?? '' + const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/) + if (fenceMatch) { + const marker = fenceMatch[1] ?? '' + if (!fenceMarker) { + fenceMarker = marker + } else if (marker[0] === fenceMarker[0] && marker.length >= fenceMarker.length) { + fenceMarker = null + } + output.push(line) + index += 1 + continue + } + + if (fenceMarker) { + output.push(line) + index += 1 + continue + } + + const header = parseMarkdownTableRow(line) + const separator = parseMarkdownTableRow(lines[index + 1] ?? '') + + if (header && separator && isMarkdownTableSeparator(separator)) { + const rows: string[][] = [header] + index += 2 + + while (index < lines.length) { + const row = parseMarkdownTableRow(lines[index] ?? '') + if (!row || isMarkdownTableSeparator(row)) { + break + } + rows.push(row) + index += 1 + } + + output.push('```') + output.push(formatMarkdownTableAsText(rows)) + output.push('```') + continue + } + + output.push(line) + index += 1 + } + + return output.join('\n') +} + const extractFencedCodeBlocks = ( text: string, store: Array<{ lang: string; body: string }> @@ -172,7 +275,9 @@ export const convertMarkdownToTelegramHtml = (input: string): string => { } try { - const normalized = input.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + const normalized = convertMarkdownTablesToCodeBlocks( + input.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + ) const codeBlocks: Array<{ lang: string; body: string }> = [] const codeInlines: string[] = [] diff --git a/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts b/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts index 05e540a0b..49829a441 100644 --- a/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts +++ b/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts @@ -735,12 +735,20 @@ export class TelegramPoller { text: string, replyMarkup?: TelegramInlineKeyboardMarkup ): Promise{ - return await this.deps.client.sendMessage( - target, - convertMarkdownToTelegramHtml(text), - replyMarkup, - { parseMode: 'HTML' } - ) + try { + return await this.deps.client.sendMessage( + target, + convertMarkdownToTelegramHtml(text), + replyMarkup, + { parseMode: 'HTML' } + ) + } catch (error) { + if (this.isTelegramEntityParseError(error)) { + return await this.deps.client.sendMessage(target, text, replyMarkup) + } + + throw error + } } private async sendPendingInteractionPrompt( @@ -795,6 +803,23 @@ export class TelegramPoller { return } + if (this.isTelegramEntityParseError(error)) { + try { + await this.deps.client.editMessageText({ + target, + messageId: action.messageId, + text: action.text, + replyMarkup: action.replyMarkup ?? undefined + }) + } catch (fallbackError) { + if (this.isMessageNotModifiedError(fallbackError)) { + return + } + throw fallbackError + } + return + } + throw error } } @@ -887,6 +912,16 @@ export class TelegramPoller { ) } + private isTelegramEntityParseError(error: unknown): boolean { + return ( + error instanceof TelegramApiRequestError && + error.code === 400 && + /parse entities|can't parse entities|unsupported start tag|can't find end tag/i.test( + error.message + ) + ) + } + private isFatalPollError(error: unknown): boolean { if (error instanceof TelegramApiRequestError) { return typeof error.code === 'number' && error.code >= 400 && error.code < 500 diff --git a/src/main/presenter/skillPresenter/skillExecutionService.ts b/src/main/presenter/skillPresenter/skillExecutionService.ts index 1eaa86a88..c10afe7df 100644 --- a/src/main/presenter/skillPresenter/skillExecutionService.ts +++ b/src/main/presenter/skillPresenter/skillExecutionService.ts @@ -103,7 +103,7 @@ export class SkillExecutionService { ) if (input.stdin) { - backgroundExecSessionManager.write( + await backgroundExecSessionManager.write( options.conversationId, result.sessionId, input.stdin, diff --git a/src/main/presenter/toolPresenter/agentTools/agentBashHandler.ts b/src/main/presenter/toolPresenter/agentTools/agentBashHandler.ts index dfa2c8f0b..5ba12ed2f 100644 --- a/src/main/presenter/toolPresenter/agentTools/agentBashHandler.ts +++ b/src/main/presenter/toolPresenter/agentTools/agentBashHandler.ts @@ -288,7 +288,12 @@ export class AgentBashHandler { outputPrefix: options.outputPrefix }) - backgroundExecSessionManager.write(conversationId, session.sessionId, options.stdin ?? '', true) + await backgroundExecSessionManager.write( + conversationId, + session.sessionId, + options.stdin ?? '', + true + ) const yielded = await backgroundExecSessionManager.waitForCompletionOrYield( conversationId, @@ -584,7 +589,12 @@ export class AgentBashHandler { }) if (options.stdin !== undefined) { - backgroundExecSessionManager.write(conversationId, result.sessionId, options.stdin, true) + await backgroundExecSessionManager.write( + conversationId, + result.sessionId, + options.stdin, + true + ) } return { diff --git a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts index c1c239c29..b533308b3 100644 --- a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts +++ b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts @@ -696,7 +696,7 @@ export class AgentToolManager { switch (action) { case 'list': { - const sessions = backgroundExecSessionManager.list(conversationId) + const sessions = await backgroundExecSessionManager.list(conversationId) return { content: JSON.stringify({ status: 'ok', sessions }, null, 2) } @@ -731,7 +731,7 @@ export class AgentToolManager { if (!sessionId) { throw new Error('sessionId is required for write action') } - backgroundExecSessionManager.write(conversationId, sessionId, data ?? '', eof) + await backgroundExecSessionManager.write(conversationId, sessionId, data ?? '', eof) return { content: JSON.stringify({ status: 'ok', sessionId }) } @@ -751,7 +751,7 @@ export class AgentToolManager { if (!sessionId) { throw new Error('sessionId is required for clear action') } - backgroundExecSessionManager.clear(conversationId, sessionId) + await backgroundExecSessionManager.clear(conversationId, sessionId) return { content: JSON.stringify({ status: 'ok', sessionId }) } diff --git a/src/renderer/src/stores/ui/pendingInput.ts b/src/renderer/src/stores/ui/pendingInput.ts index e9531e871..e56c249e6 100644 --- a/src/renderer/src/stores/ui/pendingInput.ts +++ b/src/renderer/src/stores/ui/pendingInput.ts @@ -19,7 +19,7 @@ export const usePendingInputStore = defineStore('pendingInput', () => { .filter((item) => item.mode === 'queue') .sort((left, right) => (left.queueOrder ?? 0) - (right.queueOrder ?? 0)) ) - const activeCount = computed(() => items.value.length) + const activeCount = computed(() => queueItems.value.length) const isAtCapacity = computed(() => activeCount.value >= MAX_PENDING_INPUTS) async function loadPendingInputs(sessionId: string): Promise { diff --git a/test/main/lib/agentRuntime/backgroundExecSessionManager.test.ts b/test/main/lib/agentRuntime/backgroundExecSessionManager.test.ts index c525e6511..3f3c7b20b 100644 --- a/test/main/lib/agentRuntime/backgroundExecSessionManager.test.ts +++ b/test/main/lib/agentRuntime/backgroundExecSessionManager.test.ts @@ -4,6 +4,10 @@ import { spawn } from 'child_process' import fs from 'fs' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const { mockUtilityProcessFork } = vi.hoisted(() => ({ + mockUtilityProcessFork: vi.fn() +})) + vi.mock('child_process', () => ({ spawn: vi.fn() })) @@ -11,6 +15,9 @@ vi.mock('child_process', () => ({ vi.mock('electron', () => ({ app: { getPath: vi.fn((name: string) => (name === 'userData' ? '/mock/userData' : '/mock/home')) + }, + utilityProcess: { + fork: mockUtilityProcessFork } })) @@ -28,7 +35,10 @@ vi.mock('@shared/logger', () => ({ } })) -import { BackgroundExecSessionManager } from '@/lib/agentRuntime/backgroundExecSessionManager' +import { + BackgroundExecSessionManager, + backgroundExecSessionManager +} from '@/lib/agentRuntime/backgroundExecSessionManager' class MockStream extends EventEmitter {} @@ -43,6 +53,11 @@ class MockChildProcess extends EventEmitter { pid = 321 } +class MockUtilityProcess extends EventEmitter { + postMessage = vi.fn() + kill = vi.fn() +} + function mockStats(kind: 'file' | 'directory'): fs.Stats { return { isFile: () => kind === 'file', @@ -63,6 +78,7 @@ describe('BackgroundExecSessionManager', () => { beforeEach(() => { manager = new BackgroundExecSessionManager() clearInterval((manager as never).cleanupIntervalId) + mockUtilityProcessFork.mockReset() vi.spyOn(fs, 'existsSync').mockReturnValue(true) vi.spyOn(fs, 'statSync').mockImplementation((candidate) => String(candidate).includes('workspace') ? mockStats('directory') : mockStats('file') @@ -407,3 +423,88 @@ describe('BackgroundExecSessionManager', () => { }) }) }) + +describe('backgroundExecSessionManager utility proxy', () => { + const resetProxyState = () => { + const proxy = backgroundExecSessionManager as any + proxy.host = null + proxy.hostReady = null + proxy.shuttingDown = false + proxy.activeSessions.clear() + proxy.crashedSessions.clear() + proxy.pendingRequests.clear() + } + + beforeEach(() => { + mockUtilityProcessFork.mockReset() + resetProxyState() + }) + + afterEach(() => { + resetProxyState() + }) + + it('forks the main bootstrap entrypoint for the utility host', async () => { + const host = new MockUtilityProcess() + mockUtilityProcessFork.mockReturnValue(host) + + const startPromise = (backgroundExecSessionManager as any).startHost() + await vi.waitFor(() => { + expect(mockUtilityProcessFork).toHaveBeenCalled() + }) + host.emit('spawn') + + await expect(startPromise).resolves.toBe(host) + expect(mockUtilityProcessFork).toHaveBeenCalledWith( + expect.stringMatching(/[\\/]src[\\/]main[\\/]index\.js$/), + ['--deepchat-exec-utility-host'], + expect.objectContaining({ + serviceName: 'DeepChat Exec Utility', + env: expect.objectContaining({ + DEEPCHAT_EXEC_UTILITY_HOST: '1' + }) + }) + ) + }) + + it('returns crashed completion results without starting a fresh utility host', async () => { + const proxy = backgroundExecSessionManager as any + proxy.crashedSessions.set('bg_crashed', { + conversationId: 'conv-1', + sessionId: 'bg_crashed', + command: 'pnpm test', + createdAt: 1, + lastAccessedAt: 1 + }) + + await expect( + backgroundExecSessionManager.waitForCompletionOrYield('conv-1', 'bg_crashed', 10) + ).resolves.toEqual({ + kind: 'completed', + result: { + status: 'error', + output: expect.stringContaining('pnpm test'), + exitCode: null, + offloaded: false, + timedOut: false + } + }) + expect(mockUtilityProcessFork).not.toHaveBeenCalled() + }) + + it('removes crashed sessions locally without RPC', async () => { + const proxy = backgroundExecSessionManager as any + proxy.crashedSessions.set('bg_crashed', { + conversationId: 'conv-1', + sessionId: 'bg_crashed', + command: 'pnpm test', + createdAt: 1, + lastAccessedAt: 1 + }) + + await backgroundExecSessionManager.remove('conv-1', 'bg_crashed') + + expect(proxy.crashedSessions.has('bg_crashed')).toBe(false) + expect(mockUtilityProcessFork).not.toHaveBeenCalled() + }) +}) diff --git a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts index 466e3425b..5452e22af 100644 --- a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts +++ b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts @@ -117,6 +117,69 @@ function createMockSqlitePresenter() { summary_cursor_order_seq: 1, summary_updated_at: null } + const pendingRows: any[] = [] + let pendingRowClock = 1 + const pendingInputsTable = { + insert: vi.fn((input: any) => { + const now = pendingRowClock++ + const existingIndex = pendingRows.findIndex((row) => row.id === input.id) + const row = { + id: input.id, + session_id: input.sessionId ?? input.session_id, + mode: input.mode, + state: input.state, + payload_json: input.payloadJson ?? input.payload_json, + queue_order: input.queueOrder ?? input.queue_order ?? null, + claimed_at: input.claimedAt ?? input.claimed_at ?? null, + consumed_at: input.consumedAt ?? input.consumed_at ?? null, + created_at: now, + updated_at: now + } + if (existingIndex >= 0) { + pendingRows.splice(existingIndex, 1, row) + } else { + pendingRows.push(row) + } + }), + get: vi.fn((id: string) => pendingRows.find((row) => row.id === id)), + listBySession: vi.fn((sessionId: string) => + pendingRows.filter((row) => row.session_id === sessionId) + ), + listClaimed: vi.fn(() => pendingRows.filter((row) => row.state === 'claimed')), + listActiveBySession: vi.fn((sessionId: string) => + pendingRows.filter((row) => row.session_id === sessionId && row.state !== 'consumed') + ), + countActiveBySession: vi.fn( + (sessionId: string) => + pendingRows.filter( + (row) => + row.session_id === sessionId && + row.state !== 'consumed' && + !(row.mode === 'queue' && row.state === 'claimed') + ).length + ), + update: vi.fn((id: string, patch: Record ) => { + const row = pendingRows.find((item) => item.id === id) + if (!row) { + return + } + Object.assign(row, patch, { updated_at: pendingRowClock++ }) + }), + delete: vi.fn((id: string) => { + for (let index = pendingRows.length - 1; index >= 0; index -= 1) { + if (pendingRows[index].id === id) { + pendingRows.splice(index, 1) + } + } + }), + deleteBySession: vi.fn((sessionId: string) => { + for (let index = pendingRows.length - 1; index >= 0; index -= 1) { + if (pendingRows[index].session_id === sessionId) { + pendingRows.splice(index, 1) + } + } + }) + } const deepchatMessagesTable = { insert: vi.fn(), updateContent: vi.fn(), @@ -231,17 +294,7 @@ function createMockSqlitePresenter() { deleteByMessageIds: vi.fn(), deleteBySessionId: vi.fn() }, - deepchatPendingInputsTable: { - insert: vi.fn(), - get: vi.fn(), - listBySession: vi.fn().mockReturnValue([]), - listClaimed: vi.fn().mockReturnValue([]), - listActiveBySession: vi.fn().mockReturnValue([]), - countActiveBySession: vi.fn().mockReturnValue(0), - update: vi.fn(), - delete: vi.fn(), - deleteBySession: vi.fn() - } + deepchatPendingInputsTable: pendingInputsTable } as any } @@ -742,7 +795,7 @@ describe('AgentRuntimePresenter', () => { ) }) - it('steers during pre-stream setup without starting a parallel turn', async () => { + it('queues steer during pre-stream setup and drains it as the next visible turn', async () => { let releaseTools: (() => void) | null = null toolPresenter.getAllToolDefinitions.mockImplementationOnce( () => @@ -761,20 +814,21 @@ describe('AgentRuntimePresenter', () => { releaseTools?.() await firstProcess - let steeredUserInsert: any = null for (let attempt = 0; attempt < 20; attempt += 1) { - steeredUserInsert = sqlitePresenter.deepchatMessagesTable.insert.mock.calls.find( - ([row]) => row.role === 'user' - )?.[0] - if (steeredUserInsert) { + if ((processStream as ReturnType ).mock.calls.length > 1) { break } await new Promise((resolve) => setTimeout(resolve, 0)) } - expect(steeredUserInsert).toBeTruthy() - expect(JSON.parse(steeredUserInsert.content).text).toBe('Refine before stream') - expect(processStream).toHaveBeenCalledTimes(1) + const userInserts = sqlitePresenter.deepchatMessagesTable.insert.mock.calls + .map(([row]) => row) + .filter((row) => row.role === 'user') + + expect(userInserts).toHaveLength(2) + expect(JSON.parse(userInserts[0].content).text).toBe('First prompt') + expect(JSON.parse(userInserts[1].content).text).toBe('Refine before stream') + expect(processStream).toHaveBeenCalledTimes(2) for (let attempt = 0; attempt < 20; attempt += 1) { if ((await agent.getSessionState('s1'))?.status === 'idle') { @@ -785,53 +839,25 @@ describe('AgentRuntimePresenter', () => { expect((await agent.getSessionState('s1'))?.status).toBe('idle') }) - it('interrupts an active stream for steer without marking the partial assistant as error', async () => { + it('queues active stream steer without aborting the current stream', async () => { + let releaseFirstStream: (() => void) | null = null + let firstAbortSignal: AbortSignal | null = null ;(processStream as ReturnType ) .mockImplementationOnce( async (params: { io: { abortSignal: AbortSignal } }) => await new Promise((resolve) => { - params.io.abortSignal.addEventListener( - 'abort', - () => { - resolve({ - status: 'aborted', - stopReason: 'user_stop', - errorMessage: 'common.error.userCanceledGeneration' - }) - }, - { once: true } - ) + firstAbortSignal = params.io.abortSignal + releaseFirstStream = () => + resolve({ + status: 'completed', + stopReason: 'complete' + }) }) ) .mockResolvedValueOnce({ status: 'completed', stopReason: 'complete' }) - sqlitePresenter.deepchatMessagesTable.get.mockReturnValue({ - id: 'mock-msg-id', - session_id: 's1', - order_seq: 2, - role: 'assistant', - content: JSON.stringify([ - { - type: 'content', - content: 'partial', - status: 'pending', - timestamp: 1 - }, - { - type: 'error', - content: 'common.error.userCanceledGeneration', - status: 'error', - timestamp: 2 - } - ]), - status: 'pending', - is_context_edge: 0, - metadata: null, - created_at: 1, - updated_at: 1 - }) await agent.initSession('s1', { providerId: 'openai', modelId: 'gpt-4' }) const firstProcess = agent.processMessage('s1', 'First prompt') @@ -844,6 +870,19 @@ describe('AgentRuntimePresenter', () => { } await agent.steerActiveTurn('s1', 'Refine active stream') + await agent.steerActiveTurn('s1', 'Add second steer note') + expect(firstAbortSignal?.aborted).toBe(false) + expect(processStream).toHaveBeenCalledTimes(1) + expect((processStream as ReturnType ).mock.calls[0][0]).toEqual( + expect.objectContaining({ + shouldYieldForPendingInput: expect.any(Function) + }) + ) + expect( + (processStream as ReturnType ).mock.calls[0][0].shouldYieldForPendingInput() + ).toBe(true) + + releaseFirstStream?.() await firstProcess for (let attempt = 0; attempt < 20; attempt += 1) { @@ -853,26 +892,20 @@ describe('AgentRuntimePresenter', () => { await new Promise((resolve) => setTimeout(resolve, 0)) } - expect(sqlitePresenter.deepchatMessagesTable.updateStatus).toHaveBeenCalledWith( - 'mock-msg-id', - 'sent' - ) - expect(sqlitePresenter.deepchatMessagesTable.updateContent).toHaveBeenCalledWith( - 'mock-msg-id', - JSON.stringify([ - { - type: 'content', - content: 'partial', - status: 'success', - timestamp: 1 - } - ]) - ) expect(sqlitePresenter.deepchatMessagesTable.updateContentAndStatus).not.toHaveBeenCalledWith( 'mock-msg-id', expect.any(String), 'error' ) + const userInserts = sqlitePresenter.deepchatMessagesTable.insert.mock.calls + .map(([row]) => row) + .filter((row) => row.role === 'user') + + expect(userInserts).toHaveLength(2) + expect(JSON.parse(userInserts[0].content).text).toBe('First prompt') + expect(JSON.parse(userInserts[1].content).text).toBe( + 'Refine active stream\n\nAdd second steer note' + ) expect(processStream).toHaveBeenCalledTimes(2) for (let attempt = 0; attempt < 20; attempt += 1) { diff --git a/test/main/presenter/agentRuntimePresenter/pendingInputCoordinator.test.ts b/test/main/presenter/agentRuntimePresenter/pendingInputCoordinator.test.ts new file mode 100644 index 000000000..343bfb5be --- /dev/null +++ b/test/main/presenter/agentRuntimePresenter/pendingInputCoordinator.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PendingInputCoordinator } from '@/presenter/agentRuntimePresenter/pendingInputCoordinator' +import type { PendingSessionInputRecord } from '@shared/types/agent-interface' + +vi.mock('@/eventbus', () => ({ + eventBus: { + sendToRenderer: vi.fn() + }, + SendTarget: { + ALL_WINDOWS: 'all' + } +})) + +vi.mock('@/events', () => ({ + SESSION_EVENTS: { + PENDING_INPUTS_UPDATED: 'session:pending-inputs-updated' + } +})) + +vi.mock('@/routes/publishDeepchatEvent', () => ({ + publishDeepchatEvent: vi.fn() +})) + +function createRecord( + id: string, + sessionId: string, + mode: PendingSessionInputRecord['mode'] +): PendingSessionInputRecord { + return { + id, + sessionId, + mode, + state: 'claimed', + payload: { + text: id, + files: [] + }, + queueOrder: mode === 'queue' ? 1 : null, + claimedAt: 1, + consumedAt: null, + createdAt: 1, + updatedAt: 1 + } +} + +function createCoordinator(records: Map ) { + const store = { + getInput: vi.fn((itemId: string) => records.get(itemId) ?? null), + releaseClaimedQueueInput: vi.fn((itemId: string) => records.get(itemId)!), + releaseClaimedInput: vi.fn((itemId: string) => records.get(itemId)!), + consumeQueueInput: vi.fn((itemId: string) => { + records.delete(itemId) + }), + consumeSteerInput: vi.fn((itemId: string) => { + const record = records.get(itemId) + if (record) { + records.set(itemId, { + ...record, + state: 'consumed', + consumedAt: 2 + }) + } + }) + } + + return { + coordinator: new PendingInputCoordinator(store as any), + store + } +} + +describe('PendingInputCoordinator claimed input ownership', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('does not release a claimed queue input from another session', () => { + const records = new Map ([ + ['queue-1', createRecord('queue-1', 'session-2', 'queue')] + ]) + const { coordinator, store } = createCoordinator(records) + + expect(() => coordinator.releaseClaimedQueueInput('session-1', 'queue-1')).toThrow( + 'does not belong to session session-1' + ) + expect(store.releaseClaimedQueueInput).not.toHaveBeenCalled() + }) + + it('does not consume a claimed steer input from another session', () => { + const records = new Map ([ + ['steer-1', createRecord('steer-1', 'session-2', 'steer')] + ]) + const { coordinator, store } = createCoordinator(records) + + expect(() => coordinator.consumeSteerInput('session-1', 'steer-1')).toThrow( + 'does not belong to session session-1' + ) + expect(store.consumeSteerInput).not.toHaveBeenCalled() + }) +}) diff --git a/test/main/presenter/agentRuntimePresenter/process.test.ts b/test/main/presenter/agentRuntimePresenter/process.test.ts index b84f8c5dd..dde564b4b 100644 --- a/test/main/presenter/agentRuntimePresenter/process.test.ts +++ b/test/main/presenter/agentRuntimePresenter/process.test.ts @@ -356,6 +356,49 @@ describe('processStream', () => { expect(toolResultMsg.content).toBe('Sunny, 72F') }) + it('yields after completed tool calls when a pending input should run next', async () => { + const coreStream = vi.fn(() => + (async function* () { + yield { + type: 'tool_call_start', + tool_call_id: 'tc1', + tool_call_name: 'get_weather' + } as LLMCoreStreamEvent + yield { + type: 'tool_call_end', + tool_call_id: 'tc1', + tool_call_arguments_complete: '{}' + } as LLMCoreStreamEvent + yield { type: 'stop', stop_reason: 'tool_use' } as LLMCoreStreamEvent + })() + ) as unknown as ProcessParams['coreStream'] + + const shouldYieldForPendingInput = vi.fn(() => true) + const toolPresenter = createMockToolPresenter({ get_weather: 'Sunny, 72F' }) + const params = createParams({ + coreStream, + toolPresenter, + tools: [makeTool('get_weather')], + shouldYieldForPendingInput + }) + + const promise = processStream(params) + await vi.runAllTimersAsync() + const result = await promise + + expect(coreStream).toHaveBeenCalledTimes(1) + expect(toolPresenter.callTool).toHaveBeenCalledTimes(1) + expect(shouldYieldForPendingInput).toHaveBeenCalledTimes(1) + expect(result).toMatchObject({ + status: 'completed', + stopReason: 'pending_input' + }) + + const finalizedBlocks = (messageStore.finalizeAssistantMessage as ReturnType ).mock + .calls[0][1] + expect(finalizedBlocks[0].tool_call.response).toBe('Sunny, 72F') + }) + it('refreshes tools for the next loop iteration after skill_view activates a skill', async () => { let callCount = 0 const toolPresenter = { diff --git a/test/main/presenter/agentSessionPresenter/integration.test.ts b/test/main/presenter/agentSessionPresenter/integration.test.ts index 383e98636..06480bac6 100644 --- a/test/main/presenter/agentSessionPresenter/integration.test.ts +++ b/test/main/presenter/agentSessionPresenter/integration.test.ts @@ -1035,7 +1035,7 @@ describe('Integration: multi-turn context', () => { await expect(agentPresenter.listPendingInputs(session.id)).resolves.toEqual([]) }) - it('injects steer inputs before the next queued user message', async () => { + it('drains converted steer inputs as visible user messages before queued messages', async () => { let releaseFirstTurn: (() => void) | null = null const providerInstance = { coreStream: vi @@ -1068,26 +1068,40 @@ describe('Integration: multi-turn context', () => { await agentPresenter.convertPendingInputToSteer(session.id, pendingInputs[0].id) releaseFirstTurn?.() - await new Promise((r) => setTimeout(r, 80)) + await vi.waitFor(() => { + expect(providerInstance.coreStream).toHaveBeenCalledTimes(3) + }) - expect(providerInstance.coreStream).toHaveBeenCalledTimes(2) + expect(providerInstance.coreStream).toHaveBeenCalledTimes(3) const secondCallMessages = providerInstance.coreStream.mock.calls[1][0] - const trailingUserMessages = secondCallMessages.filter( + const secondCallUserMessages = secondCallMessages.filter( + (message: any) => message.role === 'user' + ) + const thirdCallMessages = providerInstance.coreStream.mock.calls[2][0] + const thirdCallUserMessages = thirdCallMessages.filter( (message: any) => message.role === 'user' ) - expect(trailingUserMessages[trailingUserMessages.length - 2]).toEqual({ + expect(secondCallUserMessages[secondCallUserMessages.length - 1]).toEqual({ role: 'user', content: 'Steer instruction' }) - expect(trailingUserMessages[trailingUserMessages.length - 1]).toEqual({ + expect(thirdCallUserMessages[thirdCallUserMessages.length - 1]).toEqual({ role: 'user', content: 'Queued target' }) + + const messages = sqlitePresenter.deepchatMessagesTable.getBySession(session.id) + const userMessages = messages.filter((message: any) => message.role === 'user') + expect(userMessages.map((message: any) => JSON.parse(message.content).text)).toEqual([ + 'Turn one', + 'Steer instruction', + 'Queued target' + ]) await expect(agentPresenter.listPendingInputs(session.id)).resolves.toEqual([]) }) - it('rebudgets long steer inputs before streaming the next queued turn', async () => { + it('rebudgets long converted steer inputs as their own visible turn', async () => { let releaseFirstTurn: (() => void) | null = null const firstPrompt = 'P'.repeat(2000) const firstResponse = 'R'.repeat(2000) @@ -1138,27 +1152,33 @@ describe('Integration: multi-turn context', () => { await agentPresenter.convertPendingInputToSteer(session.id, pendingInputs[0].id) releaseFirstTurn?.() - await new Promise((r) => setTimeout(r, 80)) + await vi.waitFor(() => { + expect(providerInstance.coreStream).toHaveBeenCalledTimes(3) + }) - expect(providerInstance.coreStream).toHaveBeenCalledTimes(2) + expect(providerInstance.coreStream).toHaveBeenCalledTimes(3) const secondCallMessages = providerInstance.coreStream.mock.calls[1][0] const secondCallContents = secondCallMessages.map((message: any) => typeof message.content === 'string' ? message.content : JSON.stringify(message.content) ) - const trailingUserMessages = secondCallMessages.filter( + const secondCallUserMessages = secondCallMessages.filter( + (message: any) => message.role === 'user' + ) + const thirdCallMessages = providerInstance.coreStream.mock.calls[2][0] + const thirdCallUserMessages = thirdCallMessages.filter( (message: any) => message.role === 'user' ) expect(secondCallContents).not.toContain(firstPrompt) expect(secondCallContents).not.toContain(firstResponse) expect(estimateMessagesTokens(secondCallMessages) + 128).toBeLessThanOrEqual(2048) - expect(trailingUserMessages[trailingUserMessages.length - 2].content).toEqual( + expect(secondCallUserMessages[secondCallUserMessages.length - 1].content).toEqual( expect.stringContaining('[Attached File 1]') ) - expect(trailingUserMessages[trailingUserMessages.length - 2].content).toEqual( + expect(secondCallUserMessages[secondCallUserMessages.length - 1].content).toEqual( expect.stringContaining('steer.txt') ) - expect(trailingUserMessages[trailingUserMessages.length - 1]).toEqual({ + expect(thirdCallUserMessages[thirdCallUserMessages.length - 1]).toEqual({ role: 'user', content: 'Queued target' }) diff --git a/test/main/presenter/llmProviderPresenter/ollamaProvider.test.ts b/test/main/presenter/llmProviderPresenter/ollamaProvider.test.ts index 1dff63648..3cda83053 100644 --- a/test/main/presenter/llmProviderPresenter/ollamaProvider.test.ts +++ b/test/main/presenter/llmProviderPresenter/ollamaProvider.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ModelType } from '../../../../src/shared/model' import type { IConfigPresenter, @@ -35,6 +35,12 @@ vi.mock('@shared/logger', () => ({ } })) +vi.mock('@electron-toolkit/utils', () => ({ + is: { + dev: false + } +})) + vi.mock('../../../../src/main/presenter/devicePresenter', () => ({ DevicePresenter: { getDefaultHeaders: () => ({}) @@ -82,10 +88,12 @@ const createModel = ( describe('OllamaProvider.fetchModels', () => { let configPresenter: IConfigPresenter let provider: LLM_PROVIDER + const originalAllowInsecureTls = process.env.DEEPCHAT_ALLOW_INSECURE_TLS beforeEach(() => { mockOllamaConstructorOptions.length = 0 mockExecFile.mockReset() + delete process.env.DEEPCHAT_ALLOW_INSECURE_TLS mockExecFile.mockImplementation((_command, _args, _options, callback) => { callback(null, '', '') }) @@ -119,6 +127,14 @@ describe('OllamaProvider.fetchModels', () => { } }) + afterEach(() => { + if (originalAllowInsecureTls === undefined) { + delete process.env.DEEPCHAT_ALLOW_INSECURE_TLS + } else { + process.env.DEEPCHAT_ALLOW_INSECURE_TLS = originalAllowInsecureTls + } + }) + it('normalizes Ollama SDK host and OpenAI-compatible runtime base URL', () => { const ollamaProvider = new OllamaProvider( { @@ -273,6 +289,38 @@ describe('OllamaProvider.fetchModels', () => { }) await expect(ollamaProvider.pullModel('qwen3:8b')).resolves.toBe(true) + expect((ollamaProvider as any).ollama.pull).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'qwen3:8b', + insecure: false, + stream: true + }) + ) + }) + + it('only enables insecure pulls behind the explicit TLS debug flag', async () => { + process.env.DEEPCHAT_ALLOW_INSECURE_TLS = '1' + const ollamaProvider = new OllamaProvider(provider, configPresenter) + ;(ollamaProvider as any).ollama = { + pull: vi.fn(async () => ({ + async *[Symbol.asyncIterator]() { + yield { status: 'success' } + } + })), + list: vi.fn(async () => ({ models: [{ ...createModel('qwen3:8b') }] })), + show: vi.fn(async () => { + throw new Error('show unavailable') + }) + } + + await expect(ollamaProvider.pullModel('qwen3:8b')).resolves.toBe(true) + expect((ollamaProvider as any).ollama.pull).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'qwen3:8b', + insecure: true, + stream: true + }) + ) }) it('treats latest tags from ollama list as a successful untagged pull', async () => { diff --git a/test/main/presenter/remoteControlPresenter/telegramMarkdown.test.ts b/test/main/presenter/remoteControlPresenter/telegramMarkdown.test.ts index 0eccce973..f3a0c95c8 100644 --- a/test/main/presenter/remoteControlPresenter/telegramMarkdown.test.ts +++ b/test/main/presenter/remoteControlPresenter/telegramMarkdown.test.ts @@ -39,6 +39,20 @@ describe('convertMarkdownToTelegramHtml', () => { expect(convertMarkdownToTelegramHtml(input)).toBe(' hello') }) + it('renders GFM pipe tables as preformatted fixed-width text', () => { + const input = '| Name | Value |\n| --- | ---: |\n| Alpha | 1 |\n| Beta | 22 |' + expect(convertMarkdownToTelegramHtml(input)).toBe( + 'Name | Value\n------|------\nAlpha | 1\nBeta | 22' + ) + }) + + it('does not convert pipe table text inside fenced code blocks', () => { + const input = '```\n| A | B |\n| --- | --- |\n| 1 | 2 |\n```' + expect(convertMarkdownToTelegramHtml(input)).toBe( + '| A | B |\n| --- | --- |\n| 1 | 2 |' + ) + }) + it('auto-closes a dangling fenced block at a chunk boundary', () => { const input = '```ts\nconst a = 1' expect(convertMarkdownToTelegramHtml(input)).toBe( diff --git a/test/main/presenter/remoteControlPresenter/telegramPoller.test.ts b/test/main/presenter/remoteControlPresenter/telegramPoller.test.ts index fdebfd0ef..aeed7ef86 100644 --- a/test/main/presenter/remoteControlPresenter/telegramPoller.test.ts +++ b/test/main/presenter/remoteControlPresenter/telegramPoller.test.ts @@ -395,6 +395,94 @@ describe('TelegramPoller', () => { await poller.stop() }) + it('retries formatted chunks as plain text when Telegram rejects entities', async () => { + const client = createClient() + const bindingStore = createBindingStore() + client.sendMessage.mockImplementation(async (_target, _text, _replyMarkup, options) => { + if (options?.parseMode === 'HTML') { + throw new TelegramApiRequestError("Bad Request: can't parse entities", 400) + } + return 100 + }) + client.getUpdates + .mockResolvedValueOnce([ + { + update_id: 1, + message: { + message_id: 20, + chat: { + id: 100, + type: 'private' + }, + from: { + id: 123 + }, + text: 'hello' + } + } + ]) + .mockImplementation(createBlockingUpdates()) + + const poller = new TelegramPoller({ + client: client as any, + parser: { + parseUpdate: vi.fn().mockReturnValue({ + kind: 'message', + updateId: 1, + chatId: 100, + messageThreadId: 0, + messageId: 20, + chatType: 'private', + fromId: 123, + text: 'hello', + command: null + }) + } as any, + router: { + handleMessage: vi.fn().mockResolvedValue({ + replies: [], + conversation: { + sessionId: 'session-1', + eventId: 'msg-1', + getSnapshot: vi.fn().mockResolvedValue({ + messageId: 'msg-1', + text: '**fallback**', + completed: true, + pendingInteraction: null + }) + } + }) + } as any, + bindingStore: bindingStore as any + }) + + await poller.start() + + await vi.waitFor(() => { + expect(client.sendMessage).toHaveBeenNthCalledWith( + 1, + { + chatId: 100, + messageThreadId: 0 + }, + 'fallback', + undefined, + { parseMode: 'HTML' } + ) + expect(client.sendMessage).toHaveBeenNthCalledWith( + 2, + { + chatId: 100, + messageThreadId: 0 + }, + '**fallback**', + undefined + ) + }) + + await poller.stop() + }) + it('streams answer text beside a persistent trace log', async () => { vi.useFakeTimers() @@ -1731,6 +1819,106 @@ describe('TelegramPoller', () => { warnSpy.mockRestore() }) + it('ignores not-modified errors from plain edit fallback', async () => { + const client = createClient() + client.editMessageText + .mockRejectedValueOnce(new TelegramApiRequestError("Bad Request: can't parse entities", 400)) + .mockRejectedValueOnce( + new TelegramApiRequestError( + 'Bad Request: message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message', + 400 + ) + ) + client.getUpdates + .mockResolvedValueOnce([ + { + update_id: 2, + callback_query: { + id: 'callback-1', + from: { + id: 123 + }, + data: 'model:menu-token:p:0', + message: { + message_id: 30, + chat: { + id: 100, + type: 'private' + } + } + } + } + ]) + .mockImplementation(createBlockingUpdates()) + + const poller = new TelegramPoller({ + client: client as any, + parser: { + parseUpdate: vi.fn().mockReturnValue({ + kind: 'callback_query', + updateId: 2, + chatId: 100, + messageThreadId: 0, + messageId: 30, + chatType: 'private', + fromId: 123, + callbackQueryId: 'callback-1', + data: 'model:menu-token:p:0' + }) + } as any, + router: { + handleMessage: vi.fn().mockResolvedValue({ + replies: [], + outboundActions: [ + { + type: 'editMessageText', + messageId: 30, + text: '**fallback**', + replyMarkup: null + } + ], + callbackAnswer: { + text: 'Choose a model' + } + }) + } as any, + bindingStore: { + getPollOffset: vi.fn().mockReturnValue(0), + setPollOffset: vi.fn(), + getTelegramConfig: vi.fn().mockReturnValue({ + streamMode: 'draft' + }) + } as any + }) + + await poller.start() + + await vi.waitFor(() => { + expect(client.editMessageText).toHaveBeenCalledTimes(2) + }) + expect(client.editMessageText).toHaveBeenNthCalledWith(1, { + target: { + chatId: 100, + messageThreadId: 0 + }, + messageId: 30, + text: 'fallback', + replyMarkup: undefined, + parseMode: 'HTML' + }) + expect(client.editMessageText).toHaveBeenNthCalledWith(2, { + target: { + chatId: 100, + messageThreadId: 0 + }, + messageId: 30, + text: '**fallback**', + replyMarkup: undefined + }) + + await poller.stop() + }) + it('sends pending interaction prompts after completed conversation output', async () => { const client = createClient() const bindingStore = createBindingStore() diff --git a/test/renderer/components/PendingInputLane.test.ts b/test/renderer/components/PendingInputLane.test.ts index 0cf1f3621..6ac4a93ad 100644 --- a/test/renderer/components/PendingInputLane.test.ts +++ b/test/renderer/components/PendingInputLane.test.ts @@ -144,7 +144,8 @@ describe('PendingInputLane', () => { } }), buildPendingInput('queue-2', 'queue'), - buildPendingInput('queue-3', 'queue') + buildPendingInput('queue-3', 'queue'), + buildPendingInput('queue-4', 'queue') ] } }) diff --git a/test/renderer/stores/pendingInputStore.test.ts b/test/renderer/stores/pendingInputStore.test.ts index 2e3be84a0..2177077ae 100644 --- a/test/renderer/stores/pendingInputStore.test.ts +++ b/test/renderer/stores/pendingInputStore.test.ts @@ -10,10 +10,10 @@ function createDeferred() { return { promise, resolve, reject } } -const createPendingItem = (id: string, sessionId: string) => ({ +const createPendingItem = (id: string, sessionId: string, mode: 'queue' | 'steer' = 'queue') => ({ id, sessionId, - mode: 'queue' as const, + mode, state: 'pending' as const, payload: { text: id, @@ -122,4 +122,19 @@ describe('pendingInput store', () => { expect(unsubscribePendingInputsChanged).toHaveBeenCalledTimes(1) }) + + it('exposes steer inputs while counting only queue inputs toward queue capacity', async () => { + const { store, sessionClient } = await setupStore() + sessionClient.listPendingInputs.mockResolvedValueOnce([ + createPendingItem('q1', 's1'), + createPendingItem('steer1', 's1', 'steer') + ]) + + await store.loadPendingInputs('s1') + + expect(store.queueItems).toHaveLength(1) + expect(store.steerItems).toHaveLength(1) + expect(store.activeCount).toBe(1) + expect(store.isAtCapacity).toBe(false) + }) })