diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/CodingAgentExecutor.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/CodingAgentExecutor.kt index 899921a151..9c2ab59eb4 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/CodingAgentExecutor.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/CodingAgentExecutor.kt @@ -279,8 +279,9 @@ class CodingAgentExecutor( } // 错误恢复处理 - if (!executionResult.isSuccess && !executionResult.isPending) { - val command = if (toolName == "shell") params["command"] as? String else null + // 跳过用户取消的场景 - 用户取消是明确的意图,不需要显示额外的错误消息 + val wasCancelledByUser = executionResult.metadata["cancelled"] == "true" + if (!executionResult.isSuccess && !executionResult.isPending && !wasCancelledByUser) { val errorMessage = executionResult.content ?: "Unknown error" renderer.renderError("Tool execution failed: $errorMessage") @@ -468,6 +469,13 @@ class CodingAgentExecutor( return null } + // 对于用户取消的命令,不需要分析输出 + // 用户取消是明确的意图,不需要对取消前的输出做分析 + val wasCancelledByUser = executionResult.metadata["cancelled"] == "true" + if (wasCancelledByUser) { + return null + } + // 检测内容类型 val contentType = when { toolName == "glob" -> "file-list" diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/DocumentAgentExecutor.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/DocumentAgentExecutor.kt index c08d70b6c0..e151134cc5 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/DocumentAgentExecutor.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/executor/DocumentAgentExecutor.kt @@ -300,6 +300,7 @@ class DocumentAgentExecutor( * P1: Check for long content and delegate to AnalysisAgent for summarization * NOTE: Code content (from $.code.* queries) is NOT summarized to preserve actual code * NOTE: Live Session output is NOT summarized to preserve real-time terminal output + * NOTE: User cancelled commands are NOT summarized - cancellation is explicit user intent */ private suspend fun checkForLongContent( toolName: String, @@ -318,6 +319,13 @@ class DocumentAgentExecutor( return null } + // 对于用户取消的命令,不需要分析输出 + // 用户取消是明确的意图,不需要对取消前的输出做分析 + val wasCancelledByUser = executionResult.metadata["cancelled"] == "true" + if (wasCancelledByUser) { + return null + } + val isCodeContent = output.contains("📘 class ") || output.contains("⚡ fun ") || output.contains("Found") && output.contains("entities") || diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolExecutionResult.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolExecutionResult.kt index f1069c7ee0..fe7ee3d9ba 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolExecutionResult.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolExecutionResult.kt @@ -95,10 +95,11 @@ data class ToolExecutionResult( retryCount: Int = 0, metadata: Map = emptyMap() ): ToolExecutionResult { + // Preserve metadata in ToolResult.Error to enable downstream checks (e.g., cancelled flag) return ToolExecutionResult( executionId = executionId, toolName = toolName, - result = ToolResult.Error(error), + result = ToolResult.Error(error, metadata = metadata), startTime = startTime, endTime = endTime, retryCount = retryCount, diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolOrchestrator.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolOrchestrator.kt index f9dec6a878..f2dbe2d989 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolOrchestrator.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/orchestrator/ToolOrchestrator.kt @@ -37,7 +37,7 @@ class ToolOrchestrator( private val mcpConfigService: McpToolConfigService? = null, private val asyncShellExecution: Boolean = true ) { - private val logger = getLogger("ToolOrchestrator") + private val logger = getLogger("cc.unitmesh.agent.orchestrator.ToolOrchestrator") // Coroutine scope for background tasks (async shell monitoring) private val backgroundScope = CoroutineScope(Dispatchers.Default) @@ -113,7 +113,21 @@ class ToolOrchestrator( // 启动 PTY 会话 liveSession = shellExecutor.startLiveExecution(command, shellConfig) logger.debug { "Live session started: ${liveSession.sessionId}" } - + + // Register session to ShellSessionManager for cancel event handling + val managedSession = cc.unitmesh.agent.tool.shell.ShellSessionManager.registerSession( + sessionId = liveSession.sessionId, + command = liveSession.command, + workingDirectory = liveSession.workingDirectory, + processHandle = liveSession.ptyHandle + ) + // Set process handlers from LiveShellSession + managedSession.setProcessHandlers( + isAlive = { liveSession.isAlive() }, + kill = { liveSession.kill() } + ) + logger.debug { "Session registered to ShellSessionManager: ${liveSession.sessionId}" } + // 立即通知 renderer 添加 LiveTerminal(在执行之前!) logger.debug { "Adding LiveTerminal to renderer" } renderer.addLiveTerminal( @@ -279,26 +293,65 @@ class ToolOrchestrator( // Get output from ShellSessionManager (synced by UI's ProcessOutputCollector) // or fall back to LiveShellSession's stdout buffer val managedSession = cc.unitmesh.agent.tool.shell.ShellSessionManager.getSession(session.sessionId) - val output = managedSession?.getOutput()?.ifEmpty { null } ?: session.getStdout() + val rawOutput = managedSession?.getOutput()?.ifEmpty { null } ?: session.getStdout() + + // Strip ANSI escape sequences for clean output to AI + val output = cc.unitmesh.agent.tool.shell.AnsiStripper.stripAndNormalize(rawOutput) - // Update renderer with final status + // Check if this was a user cancellation + val wasCancelledByUser = managedSession?.cancelledByUser == true + + // Update renderer with final status (including cancellation info) renderer.updateLiveTerminalStatus( sessionId = session.sessionId, exitCode = exitCode, executionTimeMs = executionTimeMs, - output = output + output = output, + cancelledByUser = wasCancelledByUser ) logger.debug { "Updated renderer with session completion: ${session.sessionId}" } } catch (e: Exception) { logger.error(e) { "Error monitoring session ${session.sessionId}: ${e.message}" } + // Check if this was a user cancellation and get output from managedSession + val managedSession = cc.unitmesh.agent.tool.shell.ShellSessionManager.getSession(session.sessionId) + val wasCancelledByUser = managedSession?.cancelledByUser == true + + logger.debug { "managedSession for ${session.sessionId}: ${managedSession != null}, cancelledByUser: $wasCancelledByUser" } + + // Get output from managedSession (which was synced during waitForSession) + // or fall back to LiveShellSession's stdout buffer + val managedOutput = managedSession?.getOutput() + val sessionOutput = session.getStdout() + val rawOutput = managedOutput?.ifEmpty { null } ?: sessionOutput + + // Strip ANSI escape sequences for clean output to AI + val capturedOutput = rawOutput?.let { + cc.unitmesh.agent.tool.shell.AnsiStripper.stripAndNormalize(it) + } + + logger.debug { "Output sources - managedOutput length: ${managedOutput?.length ?: 0}, sessionOutput length: ${sessionOutput.length}, capturedOutput length: ${capturedOutput?.length ?: 0}" } + + // Build error message with captured output + val errorOutput = buildString { + appendLine("Error: ${e.message}") + if (!capturedOutput.isNullOrEmpty()) { + appendLine() + appendLine("Output before error:") + appendLine(capturedOutput) + } + } + + logger.debug { "Final errorOutput length: ${errorOutput.length}" } + // Update renderer with error status renderer.updateLiveTerminalStatus( sessionId = session.sessionId, exitCode = -1, executionTimeMs = Clock.System.now().toEpochMilliseconds() - startTime, - output = "Error: ${e.message}" + output = errorOutput, + cancelledByUser = wasCancelledByUser ) } } @@ -317,7 +370,7 @@ class ToolOrchestrator( // Use new ExecutableTool architecture for most tools // Only special-case tools that need custom handling (shell with PTY, etc.) - return when (val toolType = toolName.toToolType()) { + return when (toolName.toToolType()) { ToolType.Shell -> executeShellTool(tool, params, basicContext) ToolType.ReadFile -> executeReadFileTool(tool, params, basicContext) ToolType.WriteFile -> executeWriteFileTool(tool, params, basicContext) diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt index bc495e9c43..570595a1af 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt @@ -50,12 +50,14 @@ interface CodingAgentRenderer { * @param exitCode The exit code of the command (0 = success) * @param executionTimeMs The total execution time in milliseconds * @param output The captured output (optional, may be null if output is streamed via PTY) + * @param cancelledByUser Whether the command was cancelled by the user (exit code 137) */ fun updateLiveTerminalStatus( sessionId: String, exitCode: Int, executionTimeMs: Long, - output: String? = null + output: String? = null, + cancelledByUser: Boolean = false ) { // Default: no-op for renderers that don't support live terminals } diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/AnsiStripper.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/AnsiStripper.kt new file mode 100644 index 0000000000..f47799a9a6 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/AnsiStripper.kt @@ -0,0 +1,118 @@ +package cc.unitmesh.agent.tool.shell + +/** + * Utility object for stripping ANSI escape sequences from terminal output. + * This converts raw terminal output with color codes, cursor movements, etc. + * into clean, readable ASCII text. + */ +object AnsiStripper { + private const val ESC = '\u001B' + + /** + * Strip all ANSI escape sequences from the given text. + * Handles: + * - CSI sequences (ESC[...X) - colors, cursor movement, erase + * - OSC sequences (ESC]...BEL/ST) - window title, etc. + * - Simple escape sequences (ESC X) + * + * @param text The text containing ANSI escape sequences + * @return Clean text with all escape sequences removed + */ + fun strip(text: String): String { + if (!text.contains(ESC)) { + return text + } + + val result = StringBuilder() + var i = 0 + + while (i < text.length) { + val ch = text[i] + + when { + ch == ESC && i + 1 < text.length -> { + val next = text[i + 1] + when (next) { + '[' -> { + // CSI sequence: ESC[...X (ends with a letter) + i = skipCsiSequence(text, i + 2) + } + ']' -> { + // OSC sequence: ESC]...BEL or ESC]...ST + i = skipOscSequence(text, i + 2) + } + '(', ')' -> { + // Character set selection: ESC(X or ESC)X + i = if (i + 2 < text.length) i + 3 else text.length + } + else -> { + // Simple escape sequence: ESC X + i += 2 + } + } + } + ch == '\r' -> { + // Carriage return - skip it (will be handled with newlines) + i++ + } + else -> { + result.append(ch) + i++ + } + } + } + + return result.toString() + } + + /** + * Skip a CSI sequence starting at the given position. + * CSI sequences end with a letter (0x40-0x7E). + */ + private fun skipCsiSequence(text: String, start: Int): Int { + var i = start + while (i < text.length) { + val ch = text[i] + if (ch in '@'..'~') { + // Found the terminating character + return i + 1 + } + i++ + } + return text.length + } + + /** + * Skip an OSC sequence starting at the given position. + * OSC sequences end with BEL (0x07) or ST (ESC\). + */ + private fun skipOscSequence(text: String, start: Int): Int { + var i = start + while (i < text.length) { + val ch = text[i] + when { + ch == '\u0007' -> { + // BEL character terminates OSC + return i + 1 + } + ch == ESC && i + 1 < text.length && text[i + 1] == '\\' -> { + // ST (String Terminator) terminates OSC + return i + 2 + } + } + i++ + } + return text.length + } + + /** + * Strip ANSI sequences and also normalize line endings. + * Converts \r\n to \n and removes standalone \r. + */ + fun stripAndNormalize(text: String): String { + return strip(text) + .replace("\r\n", "\n") + .replace("\r", "") + } +} + diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt index 66b32ae770..5c705899e1 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/tool/shell/ShellSessionManager.kt @@ -53,7 +53,18 @@ object ShellSessionManager { suspend fun getSession(sessionId: String): ManagedSession? { return mutex.withLock { sessions[sessionId] } } - + + /** + * Mark a session as cancelled by user. + * This is a non-suspend function for use in UI callbacks where coroutine context may not be available. + * Note: Direct access is safe because: + * - JS/WASM are single-threaded + * - On JVM, boolean assignment is atomic and cancelledByUser is only written once + */ + fun markSessionCancelledByUser(sessionId: String) { + sessions[sessionId]?.cancelledByUser = true + } + /** * Get all active (running) sessions */ @@ -98,13 +109,19 @@ class ManagedSession( ) { private val outputBuffer = StringBuilder() private val mutex = Mutex() - + private var _exitCode: Int? = null private var _endTime: Long? = null - + val exitCode: Int? get() = _exitCode val endTime: Long? get() = _endTime + /** + * Flag to indicate if the session was cancelled by user. + * This helps distinguish between user cancellation (exit code 137) and other failures. + */ + var cancelledByUser: Boolean = false + // Callbacks for platform-specific process operations private var isAliveChecker: (() -> Boolean)? = null private var killHandler: (() -> Unit)? = null diff --git a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt index 83d47eae67..9c43b18f05 100644 --- a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt +++ b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/tool/shell/PtyShellExecutor.kt @@ -263,10 +263,13 @@ class PtyShellExecutor : ShellExecutor, LiveShellExecutor { } try { - // Note: We do NOT read output here to avoid conflicts with UI-layer output collectors - // (e.g., ProcessOutputCollector in IdeaLiveTerminalBubble). - // The UI layer is responsible for reading and displaying output in real-time. - // For CLI usage, the renderer's awaitSessionResult should handle output reading. + // Get managed session to sync output + val managedSession = ShellSessionManager.getSession(session.sessionId) + + // Note: In IDEA environment, ProcessOutputCollector in IdeaLiveTerminalBubble + // already reads from inputStream and syncs to ShellSessionManager. + // We don't start another reader here to avoid data race on the same stream. + // The output will be available via managedSession.getOutput() after process completes. val exitCode = withTimeoutOrNull(timeoutMs) { while (ptyHandle.isAlive) { @@ -283,6 +286,7 @@ class PtyShellExecutor : ShellExecutor, LiveShellExecutor { } session.markCompleted(exitCode) + managedSession?.markCompleted(exitCode) exitCode } catch (e: Exception) { logger().error(e) { "Error waiting for PTY process: ${e.message}" } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt index 272b26f47a..71c85f8f96 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt @@ -452,7 +452,8 @@ class JewelRenderer : BaseRenderer() { sessionId: String, exitCode: Int, executionTimeMs: Long, - output: String? + output: String?, + cancelledByUser: Boolean ) { // Find and update the LiveTerminalItem in the timeline _timeline.update { currentTimeline -> @@ -467,23 +468,53 @@ class JewelRenderer : BaseRenderer() { // Also notify any waiting coroutines via the session result channel sessionResultChannels[sessionId]?.let { channel -> - val result = if (exitCode == 0) { - cc.unitmesh.agent.tool.ToolResult.Success( - content = output ?: "", - metadata = mapOf( - "exit_code" to exitCode.toString(), - "execution_time_ms" to executionTimeMs.toString() + // Check cancelledByUser first to handle cancelled commands with exit code 0 + val result = when { + cancelledByUser -> { + // User cancelled - include output in the message + val errorMessage = buildString { + appendLine("⚠️ Command cancelled by user") + appendLine() + appendLine("Exit code: $exitCode (SIGKILL)") + appendLine() + if (!output.isNullOrEmpty()) { + appendLine("Output before cancellation:") + appendLine(output) + } else { + appendLine("(no output captured before cancellation)") + } + } + cc.unitmesh.agent.tool.ToolResult.Error( + message = errorMessage, + errorType = "CANCELLED_BY_USER", + metadata = mapOf( + "exit_code" to exitCode.toString(), + "execution_time_ms" to executionTimeMs.toString(), + "output" to (output ?: ""), + "cancelled" to "true" + ) ) - ) - } else { - cc.unitmesh.agent.tool.ToolResult.Error( - message = "Command failed with exit code: $exitCode", - metadata = mapOf( - "exit_code" to exitCode.toString(), - "execution_time_ms" to executionTimeMs.toString(), - "output" to (output ?: "") + } + exitCode == 0 -> { + cc.unitmesh.agent.tool.ToolResult.Success( + content = output ?: "", + metadata = mapOf( + "exit_code" to exitCode.toString(), + "execution_time_ms" to executionTimeMs.toString() + ) ) - ) + } + else -> { + cc.unitmesh.agent.tool.ToolResult.Error( + message = "Command failed with exit code: $exitCode\n${output ?: ""}", + errorType = cc.unitmesh.agent.tool.ToolErrorType.COMMAND_FAILED.code, + metadata = mapOf( + "exit_code" to exitCode.toString(), + "execution_time_ms" to executionTimeMs.toString(), + "output" to (output ?: "") + ) + ) + } } channel.trySend(result) sessionResultChannels.remove(sessionId) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt index e00ec7a761..af188e4f9b 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt @@ -410,6 +410,10 @@ class IdeaAgentViewModel( * Terminates the process and sends the current output log to AI. */ fun handleProcessCancel(cancelEvent: cc.unitmesh.devins.idea.components.timeline.CancelEvent) { + // Mark the session as cancelled by user BEFORE terminating the process + // This allows ToolOrchestrator.startSessionMonitoring() to detect user cancellation + cc.unitmesh.agent.tool.shell.ShellSessionManager.markSessionCancelledByUser(cancelEvent.sessionId) + // Terminate the process cancelEvent.process.destroyForcibly() diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt index dda0be42fc..94cb80f57e 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt @@ -451,7 +451,8 @@ class ComposeRenderer : BaseRenderer() { sessionId: String, exitCode: Int, executionTimeMs: Long, - output: String? + output: String?, + cancelledByUser: Boolean ) { // Find and update the LiveTerminalItem in the timeline val index = _timeline.indexOfFirst { @@ -478,12 +479,25 @@ class ComposeRenderer : BaseRenderer() { ) ) } else { + // Distinguish between user cancellation and other failures + val errorMessage = if (cancelledByUser) { + "Command cancelled by user" + } else { + "Command failed with exit code: $exitCode" + } + val errorType = if (cancelledByUser) { + "CANCELLED_BY_USER" + } else { + cc.unitmesh.agent.tool.ToolErrorType.COMMAND_FAILED.code + } cc.unitmesh.agent.tool.ToolResult.Error( - message = "Command failed with exit code: $exitCode", + message = errorMessage, + errorType = errorType, metadata = mapOf( "exit_code" to exitCode.toString(), "execution_time_ms" to executionTimeMs.toString(), - "output" to (output ?: "") + "output" to (output ?: ""), + "cancelled" to cancelledByUser.toString() ) ) } diff --git a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/server/cli/CodingCli.kt b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/server/cli/CodingCli.kt index 023c894217..6ab36695d8 100644 --- a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/server/cli/CodingCli.kt +++ b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/server/cli/CodingCli.kt @@ -217,12 +217,18 @@ class CodingCliRenderer : CodingAgentRenderer { sessionId: String, exitCode: Int, executionTimeMs: Long, - output: String? + output: String?, + cancelledByUser: Boolean ) { activeSessions.remove(sessionId) - val statusSymbol = if (exitCode == 0) "✓" else "✗" + val statusSymbol = when { + cancelledByUser -> "⚠" + exitCode == 0 -> "✓" + else -> "✗" + } + val statusMessage = if (cancelledByUser) "Cancelled by user" else "Exit code: $exitCode" val preview = (output ?: "").lines().take(3).joinToString(" ").take(100) - println(" $statusSymbol Exit code: $exitCode (${executionTimeMs}ms)") + println(" $statusSymbol $statusMessage (${executionTimeMs}ms)") if (preview.isNotEmpty()) { println(" $preview${if (preview.length < (output ?: "").length) "..." else ""}") } @@ -281,25 +287,63 @@ class CodingCliRenderer : CodingAgentRenderer { // Process completed val exitCode = process.exitValue() val output = session.getOutput() + val wasCancelledByUser = session.cancelledByUser + val executionTimeMs = System.currentTimeMillis() - startWait session.markCompleted(exitCode) - return if (exitCode == 0) { - cc.unitmesh.agent.tool.ToolResult.Success( - content = output.ifEmpty { "(no output)" }, - metadata = mapOf( - "exit_code" to exitCode.toString(), - "session_id" to session.sessionId + return when { + wasCancelledByUser -> { + // User cancelled the command - return a special result with output + cc.unitmesh.agent.tool.ToolResult.Error( + message = buildCancelledMessage(session.command, exitCode, output), + errorType = "CANCELLED_BY_USER", + metadata = mapOf( + "exit_code" to exitCode.toString(), + "execution_time_ms" to executionTimeMs.toString(), + "session_id" to session.sessionId, + "cancelled" to "true", + "output" to output + ) ) - ) - } else { - cc.unitmesh.agent.tool.ToolResult.Error( - message = "Command failed with exit code $exitCode:\n$output", - errorType = cc.unitmesh.agent.tool.ToolErrorType.COMMAND_FAILED.code, - metadata = mapOf( - "exit_code" to exitCode.toString(), - "session_id" to session.sessionId + } + exitCode == 0 -> { + cc.unitmesh.agent.tool.ToolResult.Success( + content = output.ifEmpty { "(no output)" }, + metadata = mapOf( + "exit_code" to exitCode.toString(), + "execution_time_ms" to executionTimeMs.toString(), + "session_id" to session.sessionId + ) ) - ) + } + else -> { + cc.unitmesh.agent.tool.ToolResult.Error( + message = "Command failed with exit code $exitCode:\n$output", + errorType = cc.unitmesh.agent.tool.ToolErrorType.COMMAND_FAILED.code, + metadata = mapOf( + "exit_code" to exitCode.toString(), + "execution_time_ms" to executionTimeMs.toString(), + "session_id" to session.sessionId + ) + ) + } + } + } + + /** + * Build a consistent cancelled message for user-cancelled commands. + */ + private fun buildCancelledMessage(command: String, exitCode: Int, output: String): String = buildString { + appendLine("⚠️ Command cancelled by user") + appendLine() + appendLine("Command: $command") + appendLine("Exit code: $exitCode (SIGKILL)") + appendLine() + if (output.isNotEmpty()) { + appendLine("Output before cancellation:") + appendLine(output) + } else { + appendLine("(no output captured before cancellation)") } } @@ -361,28 +405,53 @@ class CodingCliRenderer : CodingAgentRenderer { val output = outputBuilder.toString() activeSessions.remove(session.sessionId) + // Check if cancelled by user from ShellSessionManager + val managedSession = cc.unitmesh.agent.tool.shell.ShellSessionManager.getSession(session.sessionId) + val wasCancelledByUser = managedSession?.cancelledByUser == true + val executionTimeMs = System.currentTimeMillis() - session.startTime - println(" ${if (exitCode == 0) "✓" else "✗"} Exit code: $exitCode (${executionTimeMs}ms)") - - return if (exitCode == 0) { - cc.unitmesh.agent.tool.ToolResult.Success( - content = output.ifEmpty { "(no output)" }, - metadata = mapOf( - "exit_code" to exitCode.toString(), - "session_id" to session.sessionId, - "execution_time_ms" to executionTimeMs.toString() + val statusSymbol = when { + wasCancelledByUser -> "⚠" + exitCode == 0 -> "✓" + else -> "✗" + } + println(" $statusSymbol Exit code: $exitCode (${executionTimeMs}ms)${if (wasCancelledByUser) " [Cancelled by user]" else ""}") + + return when { + wasCancelledByUser -> { + cc.unitmesh.agent.tool.ToolResult.Error( + message = buildCancelledMessage(session.command, exitCode, output), + errorType = "CANCELLED_BY_USER", + metadata = mapOf( + "exit_code" to exitCode.toString(), + "session_id" to session.sessionId, + "execution_time_ms" to executionTimeMs.toString(), + "cancelled" to "true", + "output" to output + ) ) - ) - } else { - cc.unitmesh.agent.tool.ToolResult.Error( - message = "Command failed with exit code $exitCode:\n$output", - errorType = cc.unitmesh.agent.tool.ToolErrorType.COMMAND_FAILED.code, - metadata = mapOf( - "exit_code" to exitCode.toString(), - "session_id" to session.sessionId, - "execution_time_ms" to executionTimeMs.toString() + } + exitCode == 0 -> { + cc.unitmesh.agent.tool.ToolResult.Success( + content = output.ifEmpty { "(no output)" }, + metadata = mapOf( + "exit_code" to exitCode.toString(), + "session_id" to session.sessionId, + "execution_time_ms" to executionTimeMs.toString() + ) ) - ) + } + else -> { + cc.unitmesh.agent.tool.ToolResult.Error( + message = "Command failed with exit code $exitCode:\n$output", + errorType = cc.unitmesh.agent.tool.ToolErrorType.COMMAND_FAILED.code, + metadata = mapOf( + "exit_code" to exitCode.toString(), + "session_id" to session.sessionId, + "execution_time_ms" to executionTimeMs.toString() + ) + ) + } } }