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
133 changes: 133 additions & 0 deletions Sources/MikuCodeApp/Agent/AgentEventStream.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import Foundation

/// Normalized event decoded from one JSONL line of a headless agent run.
enum AgentStreamEvent: Equatable {
case assistantText(String)
case toolUse(name: String, detail: String?)
case completed(isError: Bool, summary: String?)
}

/// Tolerant decoder for the structured output dialects the app launches:
/// `claude -p --output-format stream-json` (top-level `type`: assistant/result),
/// `codex exec --json` ThreadEvent lines (top-level `type`: item.*/turn.*), and
/// the legacy codex `msg.type` wrapper older CLIs emitted. Unknown or non-JSON
/// lines decode to no events so interleaved diagnostics never break a run.
struct AgentEventStreamParser {
func events(fromLine line: String) -> [AgentStreamEvent] {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix("{"),
let object = try? JSONSerialization.jsonObject(with: Data(trimmed.utf8)),
let dictionary = object as? [String: Any] else {
return []
}
if let message = dictionary["msg"] as? [String: Any],
let type = message["type"] as? String {
return legacyCodexEvents(type: type, from: message)
}
guard let type = dictionary["type"] as? String else { return [] }
if type.hasPrefix("item.") || type.hasPrefix("turn.") || type == "error" {
return codexThreadEvents(type: type, from: dictionary)
}
return claudeEvents(type: type, from: dictionary)
}

private func claudeEvents(type: String, from dictionary: [String: Any]) -> [AgentStreamEvent] {
switch type {
case "assistant":
guard let message = dictionary["message"] as? [String: Any],
let content = message["content"] as? [[String: Any]] else {
return []
}
return content.compactMap { block in
switch block["type"] as? String {
case "text":
guard let text = block["text"] as? String, !text.isEmpty else { return nil }
return .assistantText(text)
case "tool_use":
return .toolUse(
name: block["name"] as? String ?? "tool",
detail: Self.toolDetail(block["input"])
)
default:
return nil
}
}
case "result":
return [.completed(
isError: dictionary["is_error"] as? Bool ?? false,
summary: dictionary["result"] as? String
)]
default:
return []
}
}

/// codex-cli ≥0.141 `exec --json`: ThreadEvent JSONL. Assistant text arrives
/// as completed `agent_message` items, commands as `command_execution`
/// items, patches as `file_change` items; the turn ends with
/// `turn.completed` / `turn.failed`.
private func codexThreadEvents(type: String, from dictionary: [String: Any]) -> [AgentStreamEvent] {
switch type {
case "item.started", "item.completed":
guard let item = dictionary["item"] as? [String: Any],
let itemType = item["type"] as? String else {
return []
}
// Command/patch items render once, at start; text renders once the
// item is complete so partial deltas never split one message.
switch (type, itemType) {
case ("item.started", "command_execution"):
return [.toolUse(name: "shell", detail: item["command"] as? String)]
case ("item.started", "file_change"):
return [.toolUse(name: "apply_patch", detail: nil)]
case ("item.completed", "agent_message"):
guard let text = item["text"] as? String, !text.isEmpty else { return [] }
return [.assistantText(text)]
case ("item.completed", "error"):
return [.completed(isError: true, summary: item["message"] as? String)]
default:
return []
}
case "turn.completed":
return [.completed(isError: false, summary: nil)]
case "turn.failed":
let error = dictionary["error"] as? [String: Any]
return [.completed(isError: true, summary: error?["message"] as? String)]
case "error":
return [.completed(isError: true, summary: dictionary["message"] as? String)]
default:
return []
}
}

private func legacyCodexEvents(type: String, from message: [String: Any]) -> [AgentStreamEvent] {
switch type {
case "agent_message":
guard let text = message["message"] as? String, !text.isEmpty else { return [] }
return [.assistantText(text)]
case "exec_command_begin":
let command = (message["command"] as? [String])?.joined(separator: " ")
return [.toolUse(name: "shell", detail: command)]
case "patch_apply_begin":
return [.toolUse(name: "apply_patch", detail: nil)]
case "error":
return [.completed(isError: true, summary: message["message"] as? String)]
case "task_complete":
return [.completed(isError: false, summary: message["last_agent_message"] as? String)]
default:
return []
}
}

/// The single most descriptive input field, shown next to the tool name
/// (file path for edits, command for shell, pattern for searches).
private static func toolDetail(_ input: Any?) -> String? {
guard let dictionary = input as? [String: Any] else { return nil }
for key in ["file_path", "command", "pattern", "path", "url", "description"] {
if let value = dictionary[key] as? String, !value.isEmpty {
return value
}
}
return nil
}
}
61 changes: 61 additions & 0 deletions Sources/MikuCodeApp/Agent/AgentTranscript.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Foundation

/// One rendered row of a thread's conversation: the user's prompt, a streamed
/// assistant message, a tool/code-change event, or a run-level notice.
enum AgentTranscriptItem: Identifiable {
case prompt(AgentPrompt)
case assistant(AgentAssistantMessage)
case tool(AgentToolEvent)
case notice(AgentRunNotice)

var id: UUID {
switch self {
case let .prompt(prompt): prompt.id
case let .assistant(message): message.id
case let .tool(event): event.id
case let .notice(notice): notice.id
}
}
}

struct AgentAssistantMessage: Identifiable {
let id: UUID
let provider: AgentProvider
var text: String

init(id: UUID = UUID(), provider: AgentProvider, text: String) {
self.id = id
self.provider = provider
self.text = text
}
}

struct AgentToolEvent: Identifiable {
let id: UUID
let name: String
let detail: String?

init(id: UUID = UUID(), name: String, detail: String?) {
self.id = id
self.name = name
self.detail = detail
}

/// Tools that modify files render as code-change rows in the transcript.
var isCodeChange: Bool {
["edit", "write", "multiedit", "notebookedit", "apply_patch"]
.contains(name.lowercased())
}
}

struct AgentRunNotice: Identifiable {
let id: UUID
let text: String
let isError: Bool

init(id: UUID = UUID(), text: String, isError: Bool) {
self.id = id
self.text = text
self.isError = isError
}
}
Loading