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 @@ -159,7 +159,7 @@ public struct ToolDetail: Equatable, Sendable {
if let review = tool.review { sections.append(.review(review)) }
let complete = tool.presentation.map { ToolRaw.complete.contains($0.type) } ?? false
wantsFullData = full == nil && !complete && !tool.running
&& (tool.arguments == nil || ToolRaw.isCut(tool.arguments) || ToolRaw.isCut(tool.result) || (tool.result == nil && tool.presentation != nil))
&& (tool.arguments == nil || ToolRaw.isCut(tool.arguments) || ToolRaw.isCut(tool.result) || tool.result == nil)
}

// MARK: Names
Expand Down
3 changes: 2 additions & 1 deletion packages/happy-ios/Sources/HappyCore/Agents/ToolRaw.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import HappyWire
/// Which tool calls keep their raw `arguments` and `result` on the phone,
/// and how much of them. A presentation that already carries what the call
/// did (a command and its output, a diff, media, an artifact) makes the raw
/// data redundant; any other call is shown on its screen from the raw data,
/// data redundant; only its bounded display fields survive (`ToolSummary`).
/// Any other call is shown on its screen from the raw data,
/// so it is kept, every string cut to `maxString` characters so one long
/// read or output can't swell the kept conversation.
public enum ToolRaw {
Expand Down
80 changes: 80 additions & 0 deletions packages/happy-ios/Sources/HappyCore/Agents/ToolSummary.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import Foundation
import HappyWire

/// The built-in tools' display fields, not arbitrary arguments that happen
/// to be called `name` or `description`. MCP inputs retain their own meaning.
public enum ToolSummary {
private static let described: Set<String> = [
"Bash", "exec_command", "run_terminal_command", "python", "apply_patch",
"BashInput", "TaskInput", "send_command_input", "write_stdin", "BashStop", "TaskStop",
"kill_command_or_subagent", "kill_session", "archive_agent", "archive_subtask", "archive_task",
"interrupt_agent", "delete_permanently", "preview_delete", "delete_artifact", "delete_calendar_event",
"delete_todo", "update_artifact", "update_calendar_event", "update_document", "update_surface",
"update_todo", "reorder_todo", "write_artifact_data", "restore_vault_file", "restore_vault_folder",
"add_surface_user", "remove_surface_user", "attach_secret", "detach_secret", "reference_secret",
"attach_compute", "detach_compute", "attach_repository", "add_mcp_server", "update_mcp_server",
"set_mcp_server_enabled", "set_bot_avatar", "set_node_avatar", "cancel_ask", "message_task",
"create_task", "create_bot", "create_calendar_event", "codex_imagegen"
]

private static func fields(_ name: String) -> [String] {
guard !name.hasPrefix("mcp__") else { return [] }
let fallback: [String]
switch name {
case "send_agent_message", "send_bot_message", "send_task_message", "update_plan": fallback = ["explanation"]
case "create_agent", "create_subtask", "create_artifact", "codex_imagegen", "create_calendar_event", "update_calendar_event", "update_artifact": fallback = ["title"]
case "create_calendar_surface", "create_chat_surface", "create_document_surface", "create_todo_surface", "set_bot_name", "set_node_name", "set_title", "remove_mcp_server", "create_task", "create_bot", "update_surface": fallback = ["name"]
case "create_vault_folder", "delete_vault_file", "delete_vault_folder", "edit_artifact_file", "edit_vault_file", "list_vault_versions", "read_artifact_file", "read_vault_file", "view_image", "write_artifact_file", "write_vault_artifact", "write_vault_file", "list_vault": fallback = ["path"]
case "copy_vault_file", "move_vault_file", "move_vault_folder": fallback = ["path", "toPath"]
case "glob_vault", "grep_vault": fallback = ["pattern"]
case "tool_search", "web_search", "read_agent_history": fallback = ["query"]
case "add_repository", "web_fetch": fallback = ["url"]
case "create_todo", "update_todo": fallback = ["text"]
default: fallback = []
}
return (described.contains(name) ? ["description"] : []) + fallback
}

/// Matches the server's Unicode-scalar limit, including combining marks
/// and emoji sequences, rather than counting UTF-16 units or graphemes.
public static func normalized(_ text: String) -> String? {
let words = text.split(whereSeparator: \.isWhitespace).joined(separator: " ")
guard !words.isEmpty else { return nil }
return String(String.UnicodeScalarView(words.unicodeScalars.prefix(120)))
}

public static func text(name: String, arguments: JSONValue?) -> String? {
for field in fields(name) {
if let value = arguments?[field]?.stringValue, let label = normalized(value) { return label }
}
if name == "request_user_input", case .array(let questions)? = arguments?["questions"] {
for question in questions {
if let value = question["question"]?.stringValue, let label = normalized(value) { return label }
}
}
return nil
}

private struct Field: CodingKey {
var stringValue: String
var intValue: Int? { nil }
init(_ value: String) { stringValue = value }
init?(stringValue: String) { self.init(stringValue) }
init?(intValue: Int) { return nil }
}

/// A rich presentation already owns its detail body. Decode only its
/// small display fields, never the potentially large patch/code payload.
static func compactArguments(name: String, from decoder: Decoder) throws -> JSONValue? {
let fields = fields(name)
guard !fields.isEmpty else { return nil }
let container = try decoder.container(keyedBy: Field.self)
var values: [String: JSONValue] = [:]
for field in fields {
if let value = try? container.decode(String.self, forKey: Field(field)), let label = normalized(value) {
values[field] = .string(label)
}
}
return values.isEmpty ? nil : .object(values)
}
}
4 changes: 3 additions & 1 deletion packages/happy-ios/Sources/HappyCore/Agents/Wire.swift
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,9 @@ extension AgentWire {
let presentation = try? container.decodeIfPresent(Presentation.self, forKey: .presentation)
let keepsRaw = ToolRaw.keeps(name: name, presentation: presentation?.type)
let result = asks ? try? container.nestedContainer(keyedBy: ResultKeys.self, forKey: .result) : nil
let rawArguments = asks || keepsRaw ? (try? container.decodeIfPresent(JSONValue.self, forKey: .arguments)) ?? nil : nil
let rawArguments = asks || keepsRaw
? (try? container.decodeIfPresent(JSONValue.self, forKey: .arguments)) ?? nil
: (try? ToolSummary.compactArguments(name: name, from: container.superDecoder(forKey: .arguments))) ?? nil
let rawResult = keepsRaw ? (try? container.decodeIfPresent(JSONValue.self, forKey: .result)) ?? nil : nil
var review = try? container.decodeIfPresent(ToolReview.self, forKey: .review)
review?.elevated = (try? container.decodeIfPresent(Bool.self, forKey: .elevated)) ?? nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public struct ToolRowLayout: Equatable, Sendable {
glyph = CGRect(x: left, y: top + (M.lineHeight - M.glyph) / 2, width: M.glyph, height: M.glyph)
let verbX = glyph.maxX + M.gap
verb = CGRect(x: verbX, y: top, width: min(ceil(verbWidth), max(0, right - verbX)), height: M.lineHeight)
let x = verb.maxX + M.gap
let x = verb.maxX + (verb.width > 0 ? M.gap : 0)
let counts = [addedWidth, deletedWidth].compactMap { $0.map { ceil($0) } }
let countsWidth = counts.reduce(0, +) + CGFloat(counts.count) * M.gap
let room = max(0, right - x - countsWidth)
Expand Down
16 changes: 13 additions & 3 deletions packages/happy-ios/Sources/HappyUI/Agents/ChatTurnPlans.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,23 @@ struct ToolWords: Equatable {
subject = url.replacingOccurrences(of: "^https?://", with: "", options: .regularExpression)
} else if name.hasPrefix("mcp__"), presentation == nil {
let parts = name.components(separatedBy: "__")
subject = parts.count >= 3 ? "\(parts[1]) · \(parts[2...].joined(separator: " "))" : Self.humanize(name)
subject = parts.count >= 3 ? "\(parts[1]) · \(parts[2...].joined(separator: "__"))" : Self.humanize(name)
} else {
subject = lower == "wait_agent" ? W.forSubagents : Self.humanize(name)
}
}
glyph = Self.glyph(name, presentation: presentation, failed: tool.failed)
if !tool.failed, presentation == nil, ["update_plan", "todo_write", "todowrite"].contains(lower) { glyph = .ionicons("checkbox-outline") }
if name.hasPrefix("mcp__") {
subject = Self.humanize(name)
mono = false
} else if let summary = ToolSummary.text(name: name, arguments: tool.arguments) {
verb = ""
mono = false
// A plan keeps its progress as well as why it changed.
subject = Self.plan(tool).map { "\($0) · \(summary)" } ?? summary
}
if !name.hasPrefix("mcp__") { subject = ToolSummary.normalized(subject) ?? "" }
}

/// A plan's line says how far it has got (`ToolDetail`'s checklist).
Expand Down Expand Up @@ -160,7 +170,7 @@ struct ToolWords: Equatable {
if let known = W.toolNames[name] { return known }
if name.hasPrefix("mcp__") {
let parts = name.components(separatedBy: "__")
if parts.count >= 3 { return "\(parts[1]) · \(parts[2...].joined(separator: " "))" }
if parts.count >= 3 { return "\(parts[1]) · \(parts[2...].joined(separator: "__"))" }
}
var text = name.replacingOccurrences(of: "[_-]+", with: " ", options: .regularExpression)
text = text.replacingOccurrences(of: "([a-z0-9])([A-Z])", with: "$1 $2", options: .regularExpression)
Expand Down Expand Up @@ -276,7 +286,7 @@ struct ToolLine {
}

var spoken: String {
tool.running ? Strings.Turns.isRunning(ToolWords.humanize(tool.name)) + ", " + words.subject : "\(words.verb) \(words.subject)"
tool.running ? Strings.Turns.isRunning(ToolWords.humanize(tool.name)) + ", " + words.subject : [words.verb, words.subject].filter { !$0.isEmpty }.joined(separator: " ")
}
}

Expand Down
13 changes: 13 additions & 0 deletions packages/happy-ios/Tests/HappyCoreTests/ToolDetailTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,19 @@ final class ToolDetailTests: XCTestCase {

// MARK: Raw arguments

func testCompactArgumentsStillFetchMissingGenericToolDetails() throws {
let call = try tool(#"{"type":"tool_call","id":"c","name":"python","status":"completed","arguments":{"description":"Check account totals"}}"#)
XCTAssertTrue(ToolDetail(call).wantsFullData)
XCTAssertFalse(ToolDetail(call, full: (.object(["code": .string("print(2)")]), .object(["output": .string("2")]))).wantsFullData)
}

func testRichToolWireKeepsOnlyBoundedDisplayArguments() throws {
let call = try tool(#"{"type":"tool_call","id":"c","name":"apply_patch","status":"completed","arguments":{"description":" Update\naccount tests ","patch":"large raw patch"},"result":{"output":"raw"},"presentation":{"type":"file_diff","files":[]}}"#)
XCTAssertEqual(call.arguments, .object(["description": .string("Update account tests")]))
XCTAssertNil(call.result)
XCTAssertEqual(call.presentation?.type, "file_diff")
}

func testAPlanIsAChecklist() throws {
let detail = ToolDetail(try tool("""
{"type":"tool_call","id":"c1","name":"update_plan","status":"completed","arguments":{"explanation":"Two steps.","plan":[{"step":"Read","status":"completed"},{"step":"Fix","status":"in_progress"},{"step":"Ship","status":"pending"}]}}
Expand Down
52 changes: 52 additions & 0 deletions packages/happy-ios/Tests/HappyCoreTests/ToolSummaryTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import HappyCore
import HappyWire
import XCTest

final class ToolSummaryTests: XCTestCase {
func testTheFiftyBuiltInDisplayFieldsMatchTheServerContract() {
let names = """
Bash exec_command run_terminal_command python apply_patch BashInput TaskInput send_command_input write_stdin
BashStop TaskStop kill_command_or_subagent kill_session archive_agent archive_subtask archive_task interrupt_agent
delete_permanently preview_delete delete_artifact delete_calendar_event delete_todo update_artifact update_calendar_event
update_document update_surface update_todo reorder_todo write_artifact_data restore_vault_file restore_vault_folder
add_surface_user remove_surface_user attach_secret detach_secret reference_secret attach_compute detach_compute
attach_repository add_mcp_server update_mcp_server set_mcp_server_enabled set_bot_avatar set_node_avatar cancel_ask
message_task create_task create_bot create_calendar_event codex_imagegen
""".split(whereSeparator: \.isWhitespace).map(String.init)
XCTAssertEqual(Set(names).count, 50)
for name in names {
XCTAssertEqual(ToolSummary.text(name: name, arguments: .object(["description": .string(" Run\naccount tests ")])), "Run account tests", name)
}
}

func testSemanticArgumentsAreNotMistakenForLabels() {
for name in ["lookup_secret", "unknown_tool", "mcp__docs__python_0123456789abcdef"] {
XCTAssertNil(ToolSummary.text(name: name, arguments: .object([
"description": .string("Semantic input"), "name": .string("PRIVATE_KEY"),
"title": .string("Input title"), "prompt": .string("Not a label"), "body": .string("Not a label")
])))
}
}

func testMappedFallbacksSkipBlankAndNonStringValues() {
XCTAssertEqual(ToolSummary.text(name: "create_task", arguments: .object(["description": .string("\n\t"), "name": .string(" Check\tstartup ")])), "Check startup")
XCTAssertEqual(ToolSummary.text(name: "update_artifact", arguments: .object(["description": .number(3), "title": .string("Report")])), "Report")
XCTAssertEqual(ToolSummary.text(name: "move_vault_file", arguments: .object(["path": .string(" "), "toPath": .string("notes/plan.md")])), "notes/plan.md")
XCTAssertEqual(ToolSummary.text(name: "send_agent_message", arguments: .object(["explanation": .string("Share findings"), "title": .string("Ignored")])), "Share findings")
}

func testAQuestionUsesItsFirstNonBlankQuestionNotContextOrAnswers() {
let arguments: JSONValue = .object(["context": .string("Not the label"), "questions": .array([
.object(["question": .string(" \n ")]), .object(["question": .string(" Which\nbranch? ")])
])])
XCTAssertEqual(ToolSummary.text(name: "request_user_input", arguments: arguments), "Which branch?")
}

func testNormalizationUsesWhitespaceAndUnicodeScalarRules() {
XCTAssertNil(ToolSummary.normalized(" \t\n\u{00A0}"))
XCTAssertEqual(ToolSummary.normalized(" First\u{00A0}second\nthird "), "First second third")
let text = String(repeating: "👩‍💻", count: 60)
XCTAssertEqual(ToolSummary.normalized(text)?.unicodeScalars.count, 120)
XCTAssertEqual(ToolSummary.normalized(text), String(repeating: "👩‍💻", count: 40))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ final class TurnLayoutsTests: XCTestCase {

// MARK: Tool rows

func testAStandaloneSummaryUsesTheVerbLaneWithoutAnEmptyGap() {
let row = ToolRowLayout(width: 390, verbWidth: 0, textWidth: 200)
XCTAssertEqual(row.text, CGRect(x: 36, y: 6, width: 338, height: 20))
XCTAssertEqual(row.glyph, CGRect(x: 16, y: 9, width: 14, height: 14))
}

func testAToolRowIsOneLineWithItsSubjectInTheRest() {
let row = ToolRowLayout(width: 390, verbWidth: 40.3, textWidth: 200)
XCTAssertEqual(row.size, CGSize(width: 390, height: 32))
Expand Down
Loading
Loading