Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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") ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,11 @@ data class ToolExecutionResult(
retryCount: Int = 0,
metadata: Map<String, String> = 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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}" }
Comment on lines +321 to +346
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Excessive Debug Logging: Multiple debug log statements (lines 321, 334, 346) are added for troubleshooting but seem verbose for production code. These logs provide very detailed debugging information that should typically be temporary.

Recommendation: Consider removing or simplifying these debug logs once the feature is stable, or use trace level logging instead of debug for this level of detail.

Copilot uses AI. Check for mistakes.

// 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
)
}
}
Expand All @@ -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()) {
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Unnecessary Variable: The toolType variable was removed but the code still works because the when expression now directly evaluates toolName.toToolType(). However, if this method is called multiple times in error paths or has side effects, it could be inefficient.

While this change works, if toToolType() is expensive or has side effects, consider keeping the variable. Otherwise, this is a minor improvement.

Suggested change
return when (toolName.toToolType()) {
val toolType = toolName.toToolType()
return when (toolType) {

Copilot uses AI. Check for mistakes.
ToolType.Shell -> executeShellTool(tool, params, basicContext)
ToolType.ReadFile -> executeReadFileTool(tool, params, basicContext)
ToolType.WriteFile -> executeWriteFileTool(tool, params, basicContext)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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", "")
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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}" }
Expand Down
Loading
Loading