Skip to content

Commit ba17354

Browse files
committed
Fix ACP v2 desktop review findings
1 parent 00e9441 commit ba17354

8 files changed

Lines changed: 413 additions & 71 deletions

File tree

fixtures/mock-acp.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def argument_value(name):
3737
close_release = option("--close-release")
3838
close_release_session = option("--close-release-session")
3939
fail_close_session = option("--fail-close-session")
40+
prompt_capability_names = set((option("--prompt-capabilities") or "image,audio").split(","))
4041
model_ids = ["mock/default", "mock/requested"]
4142

4243
if "--fail-start" in sys.argv:
@@ -86,6 +87,9 @@ def log_request(request):
8687
entry["cwd"] = params["cwd"]
8788
if request.get("method") == "session/prompt":
8889
entry["text"] = params["prompt"][0]["text"]
90+
if request.get("method") is None and "result" in request:
91+
entry["id"] = request.get("id")
92+
entry["result"] = request["result"]
8993
with log_lock:
9094
with open(request_log, "a", encoding="utf-8") as log:
9195
log.write(json.dumps(entry, separators=(",", ":")) + "\n")
@@ -127,6 +131,11 @@ def prompt(request):
127131
return
128132
respond(request["id"], {})
129133
send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": session_id, "update": {"sessionUpdate": "state_update", "state": "running"}}})
134+
if "MOCK_PERMISSION" in text:
135+
send({
136+
"jsonrpc": "2.0", "id": "permission-1", "method": "session/request_permission",
137+
"params": {"sessionId": session_id, "title": "Approve?", "options": []},
138+
})
130139
if "MOCK_HANG" in text:
131140
return
132141
should_gate = prompt_release is not None and (
@@ -147,6 +156,15 @@ def prompt(request):
147156
return
148157
if "MOCK_MEDIA" in text:
149158
text = ",".join(block.get("type", "unknown") for block in params["prompt"])
159+
if not text and any(block.get("type") in ("image", "audio") for block in params["prompt"]):
160+
send({
161+
"jsonrpc": "2.0",
162+
"method": "session/update",
163+
"params": {"sessionId": session_id, "update": {
164+
"sessionUpdate": "user_message", "messageId": "attachment-user",
165+
"content": params["prompt"],
166+
}},
167+
})
150168
if "MOCK_ECHO" in text:
151169
send({
152170
"jsonrpc": "2.0",
@@ -157,6 +175,12 @@ def prompt(request):
157175
"content": {"type": "text", "text": text},
158176
}},
159177
})
178+
if "MOCK_EMPTY_REPLACEMENT" in text:
179+
send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": session_id, "update": {"sessionUpdate": "user_message", "messageId": "empty-user", "content": []}}})
180+
if "MOCK_NULL_REPLACEMENT" in text:
181+
send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": session_id, "update": {"sessionUpdate": "user_message", "messageId": "null-user", "content": None}}})
182+
if "MOCK_OMITTED_REPLACEMENT" in text:
183+
send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": session_id, "update": {"sessionUpdate": "user_message", "messageId": "omitted-user"}}})
160184
if "MOCK_INTERRUPTION" in text:
161185
for update in [
162186
{"sessionUpdate": "agent_message_chunk", "messageId": "agent-1", "content": {"type": "text", "text": "stale response"}},
@@ -174,6 +198,14 @@ def prompt(request):
174198
send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": session_id, "update": {
175199
"sessionUpdate": "tool_call_update", "toolCallId": "background-1", "title": "Background task", "rawInput": {"background": True},
176200
}}})
201+
if "MOCK_BACKGROUND_RECOMPUTE" in text:
202+
for update in [
203+
{"sessionUpdate": "tool_call_update", "toolCallId": "late-background", "title": "Late background"},
204+
{"sessionUpdate": "tool_call_update", "toolCallId": "late-background", "rawInput": {"background": True}},
205+
{"sessionUpdate": "tool_call_update", "toolCallId": "cleared-background", "title": "Cleared background", "rawInput": {"background": True}},
206+
{"sessionUpdate": "tool_call_update", "toolCallId": "cleared-background", "rawInput": None},
207+
]:
208+
send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": session_id, "update": update}})
177209
if "MOCK_RICH_OUTPUT" in text:
178210
updates = [
179211
{
@@ -195,6 +227,7 @@ def prompt(request):
195227
"toolCallId": "call-1",
196228
"title": "Inspect files",
197229
},
230+
{"sessionUpdate": "tool_call_update", "toolCallId": "call-1"},
198231
{
199232
"sessionUpdate": "tool_call_update",
200233
"toolCallId": "call-1",
@@ -218,7 +251,7 @@ def prompt(request):
218251
if update["sessionUpdate"] == "tool_call_update" and "title" in update:
219252
sys.stderr.write("\x01kit-runtime\x01" + json.dumps({"event": "child_started", "call": "call-1:compose:shell", "tool": "shell", "summary": "echo mock", "at": 1}) + "\n")
220253
sys.stderr.flush()
221-
elif update["sessionUpdate"] == "tool_call_update":
254+
elif update["sessionUpdate"] == "tool_call_update" and "status" in update:
222255
sys.stderr.write("\x01kit-runtime\x01" + json.dumps({"event": "child_finished", "call": "call-1:compose:shell", "tool": "shell", "ok": True, "summary": "done", "millis": 2}) + "\nmock diagnostic\n")
223256
sys.stderr.flush()
224257
text = "rich done"
@@ -274,7 +307,7 @@ def close(request):
274307
respond(request["id"], {
275308
"protocolVersion": int(option("--protocol-version") or "2"),
276309
"info": {"name": "mock-acp", "version": "2.0.0"},
277-
"capabilities": {"session": {"prompt": {"image": {}, "audio": {}}}},
310+
"capabilities": {"session": {"prompt": {name: {} for name in prompt_capability_names if name}}},
278311
})
279312
elif method == "session/new":
280313
while new_release is not None and not os.path.exists(new_release):
@@ -291,7 +324,7 @@ def close(request):
291324
os._exit(0)
292325
if supports_models:
293326
result["configOptions"] = [{
294-
"id": "model",
327+
"configId": "model",
295328
"name": "Model",
296329
"category": "model",
297330
"type": "select",
@@ -355,7 +388,7 @@ def close(request):
355388
result = {}
356389
if supports_models:
357390
result["configOptions"] = [{
358-
"id": "model", "name": "Model", "category": "model", "type": "select",
391+
"configId": "model", "name": "Model", "category": "model", "type": "select",
359392
"currentValue": model_ids[0], "options": [{"value": value, "name": value} for value in model_ids],
360393
}]
361394
respond(request["id"], result)

macos/KitDesktop/Generated/ACPWireModels.generated.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,10 @@ struct ACPRequestPermissionRequest: Codable, Equatable {
181181
var options: [JSONValue]
182182
}
183183

184-
struct ACPRequestPermissionResponse: Codable, Equatable {
184+
struct ACPRequestPermissionOutcome: Codable, Equatable {
185185
var outcome: String = "cancelled"
186186
}
187+
188+
struct ACPRequestPermissionResponse: Codable, Equatable {
189+
var outcome = ACPRequestPermissionOutcome()
190+
}

macos/KitDesktop/Models/ConversationController.swift

Lines changed: 86 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,8 @@ final class ConversationController: ObservableObject {
176176
}
177177

178178
func send() {
179-
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
180-
guard acceptsInput, !text.isEmpty || !attachments.isEmpty else { return }
179+
let text = draft
180+
guard acceptsInput, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !attachments.isEmpty else { return }
181181
let files = attachments
182182
let turn = Turn.foreground(UUID())
183183
composerTransaction = ComposerTransaction(turn: turn, text: text, originalDraft: draft, attachments: files)
@@ -222,15 +222,24 @@ final class ConversationController: ObservableObject {
222222
}
223223

224224
func choose(_ option: ConfigOption, value: String) {
225-
let wireValue: ACPSessionConfigValue = option.valueType == "boolean" ? .boolean(value == "true") : .select(value)
225+
let wireValue: ACPSessionConfigValue
226+
switch option.valueType {
227+
case "select": wireValue = .select(value)
228+
case "boolean":
229+
guard value == "true" || value == "false" else { return }
230+
wireValue = .boolean(value == "true")
231+
default: return
232+
}
226233
client.setConfig(id: option.id, value: wireValue) { [weak self] result in
227234
guard let self else { return }
228235
switch result {
229236
case .failure(let error): self.fail(error)
230237
case .success(let payload):
231-
let refreshed = Self.parseConfigOptions(payload["configOptions"])
232-
if !refreshed.isEmpty { self.configOptions = refreshed }
233-
else if let index = self.configOptions.firstIndex(where: { $0.id == option.id }) { self.configOptions[index].currentValue = value }
238+
if let refreshed = payload["configOptions"] {
239+
self.configOptions = Self.parseConfigOptions(refreshed)
240+
} else if let index = self.configOptions.firstIndex(where: { $0.id == option.id }) {
241+
self.configOptions[index].currentValue = value
242+
}
234243
self.publishCurrentConfig(userSelected: true)
235244
}
236245
}
@@ -297,7 +306,8 @@ final class ConversationController: ObservableObject {
297306
case .agentThought(let message): applyMessage(message, role: .thought)
298307
case .toolCall(let update), .toolCallUpdate(let update): updateTool(update)
299308
case .toolCallContent(let id, let content): appendToolContent(id: id, content: content)
300-
case .plan(let id, let entries): applyPlan(id: id, entries: entries)
309+
case .plan(let plan): applyPlan(plan)
310+
case .planRemoved(let id): removePlan(id: id)
301311
case .usage(let usage): contextUsed = usage.used; contextSize = usage.size
302312
case .tokenUsage(let usage): tokenUsage = usage
303313
case .configOptions(let options): configOptions = Self.parseConfigOptions(options.anyValue); publishCurrentConfig()
@@ -319,18 +329,33 @@ final class ConversationController: ObservableObject {
319329
}
320330

321331
private func applyMessage(_ message: DesktopMessageUpdate, role: TranscriptRole) {
322-
if role == .user, composerTransaction != nil, message.content.allSatisfy({ consumeComposerEcho($0) }) {
323-
if let optimistic = composerTransaction?.transcriptEntryID { messageEntryIDs[message.messageId] = optimistic }
332+
if message.replace, !message.hasContent {
333+
if role == .user, let optimistic = composerTransaction?.transcriptEntryID {
334+
messageEntryIDs[message.messageId] = optimistic
335+
}
324336
return
325337
}
326-
if message.replace, !message.hasContent { return }
338+
if role == .user, composerTransaction != nil {
339+
if !message.content.isEmpty, consumeComposerEcho(message.content, requireComplete: message.replace) {
340+
if let optimistic = composerTransaction?.transcriptEntryID { messageEntryIDs[message.messageId] = optimistic }
341+
return
342+
}
343+
if message.replace, let optimistic = composerTransaction?.transcriptEntryID {
344+
messageEntryIDs[message.messageId] = optimistic
345+
}
346+
}
327347
let blocks = message.content
328348
let text = blocks.map(Self.contentText).joined()
329349
if role == .assistant { latestAssistantSource = message.replace ? text : latestAssistantSource + text }
330350
if let entryID = messageEntryIDs[message.messageId], let index = entries.firstIndex(where: { $0.id == entryID }) {
331351
if message.replace {
332352
entries[index].contentBlocks = blocks
333353
entries[index].text = String(text.suffix(256 * 1024))
354+
if role == .user {
355+
entries[index].presentation = .user(UserMessagePresentation(
356+
text: entries[index].text, media: blocks.flatMap { Self.userContent($0).media }
357+
))
358+
}
334359
} else {
335360
entries[index].contentBlocks.append(contentsOf: blocks)
336361
entries[index].text = String((entries[index].text + text).suffix(256 * 1024))
@@ -360,22 +385,29 @@ final class ConversationController: ObservableObject {
360385
}
361386
}
362387

363-
private func applyPlan(id: String, entries plan: [DesktopPlanEntry]?) {
364-
if plan == nil {
365-
if let entryID = planEntryIDs.removeValue(forKey: id) { entries.removeAll { $0.id == entryID } }
366-
transcriptRevision += 1
367-
return
368-
}
369-
let rows = plan!.map { "[" + ($0.status ?? "pending") + "] " + $0.content }.joined(separator: "\n")
370-
if let entryID = planEntryIDs[id], let index = entries.firstIndex(where: { $0.id == entryID }) {
371-
entries[index].text = rows; entries[index].formatted = Self.markdown(rows)
388+
private func applyPlan(_ plan: DesktopPlanContent) {
389+
let text: String
390+
switch plan {
391+
case .items(_, let entries):
392+
text = entries.map { "[" + ($0.status ?? "pending") + "] " + $0.content }.joined(separator: "\n")
393+
case .file(_, let uri): text = "[Plan file](" + uri + ")"
394+
case .markdown(_, let content): text = content
395+
case .unknown: return
396+
}
397+
if let entryID = planEntryIDs[plan.id], let index = entries.firstIndex(where: { $0.id == entryID }) {
398+
entries[index].text = text; entries[index].formatted = Self.markdown(text)
372399
} else {
373-
let entry = TranscriptEntry(role: .plan, title: "Plan", text: rows, formatted: Self.markdown(rows))
374-
appendEntry(entry); planEntryIDs[id] = entry.id
400+
let entry = TranscriptEntry(role: .plan, title: "Plan", text: text, formatted: Self.markdown(text))
401+
appendEntry(entry); planEntryIDs[plan.id] = entry.id
375402
}
376403
transcriptRevision += 1
377404
}
378405

406+
private func removePlan(id: String) {
407+
if let entryID = planEntryIDs.removeValue(forKey: id) { entries.removeAll { $0.id == entryID } }
408+
transcriptRevision += 1
409+
}
410+
379411
private func applyTurnState(_ state: DesktopTurnState) {
380412
let turn = Turn.autonomous(state.turnId)
381413
if state.active { reduceSettlement(.started(turn, prompt: "")) }
@@ -482,21 +514,17 @@ final class ConversationController: ObservableObject {
482514

483515
private func updateTool(_ patch: DesktopToolUpdate) {
484516
guard let id = patch.toolCallId else { return }
485-
var update = toolStates[id] ?? DesktopToolUpdate(toolCallId: id)
486-
func supplied(_ field: String) -> Bool { patch.present.isEmpty || patch.present.contains(field) }
487-
if supplied("title") { update.title = patch.cleared.contains("title") ? nil : patch.title }
488-
if supplied("kind") { update.kind = patch.cleared.contains("kind") ? nil : patch.kind }
489-
if supplied("status") { update.status = patch.cleared.contains("status") ? nil : patch.status }
490-
if supplied("content") { update.content = patch.cleared.contains("content") ? nil : patch.content }
491-
if supplied("rawInput") { update.rawInput = patch.cleared.contains("rawInput") ? nil : patch.rawInput }
492-
if supplied("rawOutput") { update.rawOutput = patch.cleared.contains("rawOutput") ? nil : patch.rawOutput }
517+
let update = (toolStates[id] ?? DesktopToolUpdate(toolCallId: id)).merging(patch)
493518
toolStates[id] = update
494519
let dictionary = Self.toolDictionary(update)
495520
if let index = entries.lastIndex(where: { $0.role == .tool && $0.toolCallID == id }) {
496521
let tool = Self.toolPresentation(dictionary)
497522
entries[index].title = tool.title
498523
entries[index].text = tool.detail
499524
entries[index].presentation = .tool(tool)
525+
if patch.present.contains("rawInput") {
526+
entries[index].backgrounded = (update.rawInput?.anyValue as? [String: Any])?["background"] as? Bool == true
527+
}
500528
entries[index].isStreaming = tool.status == .inProgress || tool.status == .pending
501529
if entries[index].isStreaming { streamingEntryIDs.insert(entries[index].id) }
502530
else { streamingEntryIDs.remove(entries[index].id) }
@@ -561,22 +589,33 @@ final class ConversationController: ObservableObject {
561589
transcriptRevision += 1
562590
}
563591

564-
private func consumeComposerEcho(_ content: DesktopContentBlock) -> Bool {
592+
private func consumeComposerEcho(_ contents: [DesktopContentBlock], requireComplete: Bool) -> Bool {
565593
guard var transaction = composerTransaction else { return false }
566-
switch content {
567-
case .text(let text):
568-
guard !transaction.echoedTextComplete else { return false }
569-
let candidate = transaction.echoedText + text
570-
guard !transaction.text.isEmpty, transaction.text.hasPrefix(candidate) else { return false }
571-
transaction.echoedText = candidate
572-
transaction.echoedTextComplete = candidate == transaction.text
573-
case .image:
574-
guard transaction.echoedAttachmentIndex < transaction.attachments.count, transaction.attachments[transaction.echoedAttachmentIndex].kind == .image else { return false }
575-
transaction.echoedAttachmentIndex += 1
576-
case .audio:
577-
guard transaction.echoedAttachmentIndex < transaction.attachments.count, transaction.attachments[transaction.echoedAttachmentIndex].kind == .audio else { return false }
578-
transaction.echoedAttachmentIndex += 1
579-
default: return false
594+
for content in contents {
595+
switch content {
596+
case .text(let text):
597+
if transaction.text.isEmpty {
598+
guard text.isEmpty else { return false }
599+
transaction.echoedTextComplete = true
600+
} else {
601+
guard !transaction.echoedTextComplete else { return false }
602+
let candidate = transaction.echoedText + text
603+
guard transaction.text.hasPrefix(candidate) else { return false }
604+
transaction.echoedText = candidate
605+
transaction.echoedTextComplete = candidate == transaction.text
606+
}
607+
case .image:
608+
guard transaction.echoedAttachmentIndex < transaction.attachments.count, transaction.attachments[transaction.echoedAttachmentIndex].kind == .image else { return false }
609+
transaction.echoedAttachmentIndex += 1
610+
case .audio:
611+
guard transaction.echoedAttachmentIndex < transaction.attachments.count, transaction.attachments[transaction.echoedAttachmentIndex].kind == .audio else { return false }
612+
transaction.echoedAttachmentIndex += 1
613+
default: return false
614+
}
615+
}
616+
if requireComplete {
617+
guard (transaction.text.isEmpty || transaction.echoedTextComplete),
618+
transaction.echoedAttachmentIndex == transaction.attachments.count else { return false }
580619
}
581620
composerTransaction = transaction
582621
return true
@@ -716,8 +755,9 @@ final class ConversationController: ObservableObject {
716755

717756
static func parseConfigOptions(_ value: Any?) -> [ConfigOption] {
718757
(value as? [[String: Any]] ?? []).compactMap { item in
719-
guard let id = (item["configId"] ?? item["id"]) as? String else { return nil }
720-
let type = item["type"] as? String ?? "select"
758+
guard let id = item["configId"] as? String,
759+
let type = item["type"] as? String,
760+
type == "select" || type == "boolean" else { return nil }
721761
let raw = item["options"] as? [[String: Any]] ?? []
722762
var groups: [ConfigGroup] = []
723763
var ungrouped: [ConfigChoice] = []

0 commit comments

Comments
 (0)