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
2 changes: 1 addition & 1 deletion Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ public enum IDEToolRegistry {
]),
"timeout_seconds": .object([
"type": .string("number"),
"description": .string("How long to wait for the assistant's reply. Default 120, capped at 600. On timeout the call returns the partial text with `done: false` — the caller can poll via ide__get_thread_messages."),
"description": .string("How long to wait for the assistant's reply. Default 20, capped at 20. On timeout the call returns the partial text with `done: false` — the caller can poll via ide__get_thread_messages."),
]),
]),
"required": .array([.string("prompt")]),
Expand Down
8 changes: 3 additions & 5 deletions Packages/Sources/RxCodeCore/WindowState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,11 @@ import SwiftUI

// MARK: - AppStorageKeys

/// Keys for app-wide UserDefaults state read directly via `@AppStorage` in views.
/// Raw UserDefaults keys used for persisted UI state.
public enum AppStorageKeys {
/// Persisted visibility of the right inspector sidebar. Views read this
/// directly with `@AppStorage` so the panel can be reliably toggled.
/// Persisted visibility of the right inspector sidebar.
public static let showRightSidebar = "showRightSidebar"
/// Last visible width of the right inspector sidebar. The panel reads this
/// as its split-view ideal width on the next launch.
/// Last visible width of the right inspector sidebar.
public static let rightInspectorWidth = "rightInspectorWidth"
}

Expand Down
154 changes: 64 additions & 90 deletions RxCode/App/AppState+CrossProject.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,58 +6,6 @@
import SwiftUI

extension AppState {
/// Drop "No response requested." text blocks from the assistant message
/// at `idx`. The marker is the model's response when a turn arrives
/// without a user prompt (ScheduleWakeup, hook re-entry) and reads as
/// noise in the chat UI.
///
/// `removeIfEmpty` controls whether a message left with no blocks is also
/// deleted. The normal stream path passes `true` to discard pure no-op
/// envelopes; the cancel path passes `false` so pausing a turn never makes
/// the partial assistant bubble disappear.
static func stripNoOpText(at idx: Int, in messages: inout [ChatMessage], removeIfEmpty: Bool = true) {
guard messages.indices.contains(idx) else { return }
messages[idx].blocks.removeAll { block in
guard let text = block.text else { return false }
return CLIMetaEnvelope.isNoResponseRequested(text.trimmingCharacters(in: .whitespacesAndNewlines))
}
if removeIfEmpty, messages[idx].blocks.isEmpty {
messages.remove(at: idx)
}
}

/// Wrap a branch briefing into a system-prompt section the agent can use as
/// background context. The briefing is auto-generated from earlier threads,
/// so it is framed as advisory rather than authoritative.
static func branchBriefingSystemPrompt(branch: String, briefing: String) -> String {
let trimmed = briefing.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "" }
return """
# Current branch briefing

The notes below are an accumulated briefing of recent work on this \
project's current branch (`\(branch)`). They are auto-generated from \
previous chat threads — treat them as background context for the user's \
request, and be aware they may be incomplete or slightly out of date.

\(trimmed)
"""
}

static func promptWithBackgroundContext(_ contexts: [String], prompt: String) -> String {
let context = contexts
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: "\n\n")
guard !context.isEmpty else { return prompt }
return """
\(context)

User request:
\(prompt)
"""
}

func processStream(
streamId: UUID,
prompt: String,
Expand All @@ -71,14 +19,19 @@
permissionMode: PermissionMode = .default,
hookSessionMode: PermissionMode? = nil,
projectId: UUID,
includeIDEMCP: Bool = true,
window: WindowState
) async {
// Mode used when registering a session with PermissionServer for hook auto-approve.
// When plan toggle is on, `permissionMode` is `.plan` (for the CLI flag) but the
// user's dropdown choice (e.g. `.auto`) should still drive the hook policy.
let registerMode = hookSessionMode ?? permissionMode
let streamStart = Date()
let debugLogPrefix = streamDebugLogPrefixes[streamId]
logger.info("[Stream:UI] starting processStream provider=\(agentProvider.rawValue, privacy: .public) stream=\(streamId) cli=\(cliSessionId ?? "new", privacy: .public) key=\(internalSessionKey, privacy: .public)")
if let debugLogPrefix {
logger.info("\(debugLogPrefix, privacy: .public) phase=processStreamStart stream=\(streamId) provider=\(agentProvider.rawValue, privacy: .public) cli=\(cliSessionId ?? "new", privacy: .public) key=\(internalSessionKey, privacy: .public)")
}

var sessionKey = internalSessionKey

Expand Down Expand Up @@ -124,6 +77,7 @@
let memoryService = self.memoryService
let marketplace = self.marketplace
let ideMCPServer = self.ideMCPServer
let shouldIncludeIDEMCP = includeIDEMCP

func logPreflight(_ label: String, detail: String = "") {
let elapsed = Date().timeIntervalSince(streamStart)
Expand All @@ -143,12 +97,14 @@
)
: []
async let currentBranchAsync = GitHelper.currentBranch(at: cwd)
async let idePortAsync = ideMCPServer.allocate(
sessionKey: capturedSessionKey,
capabilities: agentProvider.staticCapabilities
)
async let idePortAsync: UInt16? = shouldIncludeIDEMCP
? ideMCPServer.allocate(
sessionKey: capturedSessionKey,
capabilities: agentProvider.staticCapabilities
)
: nil
async let skillContextAsync: String? = marketplace.promptContext(for: agentProvider)
async let codexSkillOverridesAsync: [String] = agentProvider == .codex
async let codexSkillOverridesAsync: [String] = shouldIncludeIDEMCP && agentProvider == .codex
? await marketplace.codexConfigOverrides()
: []

Expand Down Expand Up @@ -186,7 +142,10 @@
// bridge, but they no longer block memory/git/skill resolution.
let idePort = await idePortAsync
let bridge = idePort.map { IDEMCPServer.bridgeCommand(forPort: $0) }
logPreflight("ideMCP", detail: "port=\(idePort.map(String.init) ?? "<nil>")")
logPreflight(
"ideMCP",
detail: "enabled=\(shouldIncludeIDEMCP) port=\(idePort.map(String.init) ?? "<nil>")"
)

// Session-start hooks fire once, only for a brand-new thread (no resumed
// CLI session). Their stdout is injected into this turn's agent context
Expand Down Expand Up @@ -219,8 +178,18 @@
logPreflight("skillContext", detail: "chars=0")
}
case .codex:
mcpCodexOverrides = await mcp.codexConfigOverrides(projectPath: cwd, bridgeCommand: bridge)
logPreflight("codexMCP", detail: "args=\(mcpCodexOverrides.count)")
mcpCodexOverrides = await mcp.codexConfigOverrides(
projectPath: cwd,
bridgeCommand: bridge,
disableAllServers: !shouldIncludeIDEMCP
)
if !shouldIncludeIDEMCP {
mcpCodexOverrides += ["--disable", "plugins"]
}
logPreflight(
"codexMCP",
detail: "args=\(mcpCodexOverrides.count) disabledAll=\(!shouldIncludeIDEMCP)"
)
let codexSkillOverrides = await codexSkillOverridesAsync
logPreflight("codexSkillOverrides", detail: "args=\(codexSkillOverrides.count)")
mcpCodexOverrides += codexSkillOverrides
Expand Down Expand Up @@ -285,6 +254,9 @@
} else {
let preflightElapsed = Date().timeIntervalSince(streamStart)
logger.info("[Stream:UI] backend send starting provider=\(agentProvider.rawValue, privacy: .public) stream=\(streamId) after=\(String(format: "%.2f", preflightElapsed), privacy: .public)s cwd=\(cwd, privacy: .public)")
if let debugLogPrefix {
logger.info("\(debugLogPrefix, privacy: .public) phase=backendSendStart stream=\(streamId) after=\(String(format: "%.2f", preflightElapsed), privacy: .public)s provider=\(agentProvider.rawValue, privacy: .public) cwd=\(cwd, privacy: .public)")
}
let request = BackendSendRequest(
streamId: streamId,
prompt: resolvedPrompt,
Expand All @@ -305,6 +277,9 @@
stream = await backend(for: agentProvider).send(request)
let backendReturnedElapsed = Date().timeIntervalSince(streamStart)
logger.info("[Stream:UI] backend send returned provider=\(agentProvider.rawValue, privacy: .public) stream=\(streamId) after=\(String(format: "%.2f", backendReturnedElapsed), privacy: .public)s")
if let debugLogPrefix {
logger.info("\(debugLogPrefix, privacy: .public) phase=backendSendReturned stream=\(streamId) after=\(String(format: "%.2f", backendReturnedElapsed), privacy: .public)s provider=\(agentProvider.rawValue, privacy: .public)")
}
}

startFlushTimer(for: sessionKey)
Expand Down Expand Up @@ -332,6 +307,9 @@
if eventCount == 1 {
let totalElapsed = Date().timeIntervalSince(streamStart)
logger.info("[Stream:UI] first event arrived provider=\(agentProvider.rawValue, privacy: .public) session=\(sessionKey, privacy: .public) stream=\(streamId) total=\(String(format: "%.2f", totalElapsed), privacy: .public)s awaitGap=\(String(format: "%.2f", gap), privacy: .public)s event=\(Self.streamEventLogName(event), privacy: .public)")
if let debugLogPrefix {
logger.info("\(debugLogPrefix, privacy: .public) phase=firstEvent stream=\(streamId) total=\(String(format: "%.2f", totalElapsed), privacy: .public)s awaitGap=\(String(format: "%.2f", gap), privacy: .public)s event=\(Self.streamEventLogName(event), privacy: .public)")
}
}
lastEventTime = Date()
updateState(sessionKey) { $0.lastStreamEventDate = lastEventTime }
Expand Down Expand Up @@ -625,6 +603,7 @@
}
}
}
recordStreamPartialResponseIfNeeded(streamId: streamId, sessionId: sessionKey)

case .user(let userMessage):
logger.debug("[Stream:UI] event #\(eventCount) .user (gap=\(String(format: "%.1f", gap))s, toolUseId=\(userMessage.toolUseId ?? "none"))")
Expand All @@ -636,6 +615,10 @@

case .result(let resultEvent):
logger.info("[Stream:UI] event #\(eventCount) .result (gap=\(String(format: "%.1f", gap))s, isError=\(resultEvent.isError), session=\(resultEvent.sessionId))")
if let debugLogPrefix {
let totalElapsed = Date().timeIntervalSince(streamStart)
logger.info("\(debugLogPrefix, privacy: .public) phase=resultEvent stream=\(streamId) eventCount=\(eventCount, privacy: .public) total=\(String(format: "%.2f", totalElapsed), privacy: .public)s gap=\(String(format: "%.1f", gap), privacy: .public)s isError=\(resultEvent.isError, privacy: .public) session=\(resultEvent.sessionId, privacy: .public)")
}

// With `--input-format stream-json` the CLI stays alive waiting for more
// input. Close stdin on `result` so it exits cleanly, then finalize so
Expand Down Expand Up @@ -716,6 +699,18 @@
}
if markUnread { state.hasUncheckedCompletion = true }
}
let finalAssistantText = lastAssistantResponseText(in: stateForSession(sessionKey).messages)

// Release cross-thread MCP callers as soon as the model turn
// is finalized. Session-end hooks can run follow-up work and
// persist cards afterward, but they must not delay the
// `ide__send_to_thread` JSON-RPC response.
recordStreamCompletion(
streamId: streamId,
sessionId: resultEvent.sessionId,
assistantText: finalAssistantText,
error: resultEvent.isError ? "Agent reported an error result." : nil
)

// Session-stop hooks fire when streaming stops. Before-stop
// runs *after* `finalizeStreamSession` so its status card lands
Expand All @@ -731,7 +726,7 @@
sessionId: resultEvent.sessionId,
reason: .completed,
turnDidError: resultEvent.isError,
lastAssistantText: lastAssistantResponseText(in: stateForSession(sessionKey).messages),
lastAssistantText: finalAssistantText,
hasQueuedFollowups: hasQueuedFollowups
))
if stopResult.hasError {
Expand All @@ -742,13 +737,6 @@
}
}

recordStreamCompletion(
streamId: streamId,
sessionId: resultEvent.sessionId,
assistantText: lastAssistantResponseText(in: stateForSession(sessionKey).messages),
error: resultEvent.isError ? "Agent reported an error result." : nil
)

if isFg {
window.currentSessionId = resultEvent.sessionId
if resultEvent.isError {
Expand Down Expand Up @@ -876,11 +864,15 @@
logger.debug("[Stream:UI] event #\(eventCount) .unknown (gap=\(String(format: "%.1f", gap))s, len=\(raw.count))")
}
handlePartialEvent(raw, for: sessionKey)
recordStreamPartialResponseIfNeeded(streamId: streamId, sessionId: sessionKey)
}
}

let elapsed = Date().timeIntervalSince(streamStart)
logger.info("[Stream:UI] stream ended after \(eventCount) events, \(String(format: "%.1f", elapsed))s total")
if let debugLogPrefix {
logger.info("\(debugLogPrefix, privacy: .public) phase=streamEnded stream=\(streamId) eventCount=\(eventCount, privacy: .public) total=\(String(format: "%.2f", elapsed), privacy: .public)s")
}

// Consume any remaining stderr — used as error message content below.
// If already consumed at result.isError time, this returns nil.
Expand Down Expand Up @@ -942,7 +934,7 @@
// already records a completion before reaching here — recordStreamCompletion
// is idempotent (it overwrites with the latest), but if a prior call set a
// successful completion we don't want to clobber it with an error.
if pendingStreamCompletions[streamId] == nil {
if !recordedStreamCompletionIds.contains(streamId) {
let assistantText = lastAssistantResponseText(in: stateForSession(sessionKey).messages)
let errorMsg: String? = eventCount == 0
? (stderrOutput ?? "Stream ended with no events.")
Expand All @@ -954,28 +946,10 @@
error: errorMsg
)
}
}
}

nonisolated static func streamEventLogName(_ event: StreamEvent) -> String {
switch event {
case .system(let systemEvent):
return "system.\(systemEvent.subtype)"
case .assistant:
return "assistant"
case .user:
return "user"
case .result:
return "result"
case .rateLimitEvent:
return "rateLimitEvent"
case .todoSnapshot:
return "todoSnapshot"
case .acpModelsDiscovered:
return "acpModelsDiscovered"
case .unknown:
return "unknown"
recordedStreamCompletionIds.remove(streamId)
streamPartialResponseDeliveredIds.remove(streamId)
streamDebugLogPrefixes.removeValue(forKey: streamId)
}
}

}

Check warning on line 955 in RxCode/App/AppState+CrossProject.swift

View workflow job for this annotation

GitHub Actions / swiftlint

File should contain 600 lines or less excluding comments and whitespaces: currently contains 760 (file_length)
77 changes: 77 additions & 0 deletions RxCode/App/AppState+CrossProjectHelpers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import Foundation
import RxCodeCore

extension AppState {
/// Drop "No response requested." text blocks from the assistant message
/// at `idx`. The marker is the model's response when a turn arrives
/// without a user prompt (ScheduleWakeup, hook re-entry) and reads as
/// noise in the chat UI.
///
/// `removeIfEmpty` controls whether a message left with no blocks is also
/// deleted. The normal stream path passes `true` to discard pure no-op
/// envelopes; the cancel path passes `false` so pausing a turn never makes
/// the partial assistant bubble disappear.
static func stripNoOpText(at idx: Int, in messages: inout [ChatMessage], removeIfEmpty: Bool = true) {
guard messages.indices.contains(idx) else { return }
messages[idx].blocks.removeAll { block in
guard let text = block.text else { return false }
return CLIMetaEnvelope.isNoResponseRequested(text.trimmingCharacters(in: .whitespacesAndNewlines))
}
if removeIfEmpty, messages[idx].blocks.isEmpty {
messages.remove(at: idx)
}
}

/// Wrap a branch briefing into a system-prompt section the agent can use as
/// background context. The briefing is auto-generated from earlier threads,
/// so it is framed as advisory rather than authoritative.
static func branchBriefingSystemPrompt(branch: String, briefing: String) -> String {
let trimmed = briefing.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "" }
return """
# Current branch briefing

The notes below are an accumulated briefing of recent work on this \
project's current branch (`\(branch)`). They are auto-generated from \
previous chat threads — treat them as background context for the user's \
request, and be aware they may be incomplete or slightly out of date.

\(trimmed)
"""
}

static func promptWithBackgroundContext(_ contexts: [String], prompt: String) -> String {
let context = contexts
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: "\n\n")
guard !context.isEmpty else { return prompt }
return """
\(context)

User request:
\(prompt)
"""
}

nonisolated static func streamEventLogName(_ event: StreamEvent) -> String {
switch event {
case .system(let systemEvent):
return "system.\(systemEvent.subtype)"
case .assistant:
return "assistant"
case .user:
return "user"
case .result:
return "result"
case .rateLimitEvent:
return "rateLimitEvent"
case .todoSnapshot:
return "todoSnapshot"
case .acpModelsDiscovered:
return "acpModelsDiscovered"
case .unknown:
return "unknown"
}
}
}
Loading
Loading