Skip to content

Commit dd1a548

Browse files
committed
fix: preserve desktop observations through recovery and report app focus
1 parent be9ea16 commit dd1a548

13 files changed

Lines changed: 554 additions & 105 deletions

File tree

‎apps/desktop/native/computer-use/ComputerUse.swift‎

Lines changed: 48 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -363,11 +363,25 @@ func requireForegroundTarget(pid: pid_t, windowID: String, point: CGPoint, frame
363363
}
364364
guard let top, (top[kCGWindowNumber as String] as? NSNumber)?.stringValue == windowID else { throw ComputerError("window_occluded", "The target point is covered by another window; bring the intended window forward and observe again.") }
365365
}
366-
func postMouse(pid: pid_t, type: CGEventType, point: CGPoint, button: CGMouseButton, count: Int = 1, windowID: String? = nil, frame: CGRect? = nil, release: Bool = false) throws {
366+
final class CoordinateDispatch {
367+
private(set) var hasDispatched = false
368+
func willDispatch() { hasDispatched = true }
369+
func classify(_ error: ComputerError) -> ComputerError {
370+
ComputerError(error.code, error.message, dispatchState: hasDispatched ? nil : "not_started")
371+
}
372+
}
373+
@MainActor
374+
func coordinateAction(_ operation: (CoordinateDispatch) async throws -> Void) async throws {
375+
let dispatch = CoordinateDispatch()
376+
do { try await operation(dispatch) }
377+
catch let error as ComputerError { throw dispatch.classify(error) }
378+
}
379+
func postMouse(pid: pid_t, type: CGEventType, point: CGPoint, button: CGMouseButton, count: Int = 1, windowID: String? = nil, frame: CGRect? = nil, release: Bool = false, dispatch: CoordinateDispatch) throws {
367380
guard let windowID, let frame else { throw ComputerError("invalid_window", "Mouse input requires an observed app window.") }
368381
if !release { try requireForegroundTarget(pid: pid, windowID: windowID, point: point, frame: frame) }
369382
guard let source = CGEventSource(stateID: .privateState), let event = CGEvent(mouseEventSource: source, mouseType: type, mouseCursorPosition: point, mouseButton: button) else { throw ComputerError("input_failed", "Could not create mouse event.") }
370383
event.setIntegerValueField(.mouseEventClickState, value: Int64(count))
384+
dispatch.willDispatch()
371385
event.post(tap: .cghidEventTap)
372386
}
373387

@@ -482,6 +496,7 @@ final class Driver {
482496
} catch let error as ComputerError { result["screenshotError"] = String((error.code + ": " + error.message).prefix(2000)) }
483497
catch { result["screenshotError"] = String(("capture_failed: " + String(describing: error)).prefix(2000)) }
484498
}
499+
result["isActive"] = app.isActive
485500
return result
486501
case "click", "type_text", "input_sequence", "press_key", "scroll", "drag", "set_value", "perform_action": break
487502
default: throw ComputerError("unknown_method", "Unknown computer-use method.")
@@ -508,13 +523,15 @@ final class Driver {
508523
guard ["left", "right"].contains(button), (1...3).contains(count) else { throw ComputerError("invalid_arguments", "Invalid button or clickCount.") }
509524
if p.elementId != nil && button == "left" && count == 1 { try axCheck(AXUIElementPerformAction(try snapshot.element(p.elementId), kAXPressAction as CFString)) }
510525
else {
511-
let position = try point(p, app: app, snapshot: snapshot); let mouse: CGMouseButton = button == "right" ? .right : .left
512-
let windowID = try required(p.windowId ?? snapshot.windowFrames.first(where: { $0.value.contains(position) })?.key, "windowId")
513-
let frame = try required(snapshot.windowFrames[windowID], "window frame")
514-
for click in 1...count {
515-
try checkCancellation()
516-
try postMouse(pid: pid, type: button == "right" ? .rightMouseDown : .leftMouseDown, point: position, button: mouse, count: click, windowID: windowID, frame: frame)
517-
try postMouse(pid: pid, type: button == "right" ? .rightMouseUp : .leftMouseUp, point: position, button: mouse, count: click, windowID: windowID, frame: frame, release: true)
526+
try await coordinateAction { dispatch in
527+
let position = try point(p, app: app, snapshot: snapshot); let mouse: CGMouseButton = button == "right" ? .right : .left
528+
let windowID = try required(p.windowId ?? snapshot.windowFrames.first(where: { $0.value.contains(position) })?.key, "windowId")
529+
let frame = try required(snapshot.windowFrames[windowID], "window frame")
530+
for click in 1...count {
531+
try checkCancellation()
532+
try postMouse(pid: pid, type: button == "right" ? .rightMouseDown : .leftMouseDown, point: position, button: mouse, count: click, windowID: windowID, frame: frame, dispatch: dispatch)
533+
try postMouse(pid: pid, type: button == "right" ? .rightMouseUp : .leftMouseUp, point: position, button: mouse, count: click, windowID: windowID, frame: frame, release: true, dispatch: dispatch)
534+
}
518535
}
519536
}
520537
case "type_text", "input_sequence":
@@ -594,27 +611,31 @@ final class Driver {
594611
}
595612
if !selectedAll { try postKey(pid: pid, code: code, flags: flags) }
596613
case "scroll":
597-
let position = try point(p, app: app, snapshot: snapshot); let dx = p.deltaX ?? 0; let dy = p.deltaY ?? 0
598-
guard dx.isFinite, dy.isFinite, abs(dx) <= 10000, abs(dy) <= 10000 else { throw ComputerError("invalid_arguments", "Scroll deltas exceed bounds.") }
599-
guard let event = CGEvent(scrollWheelEvent2Source: CGEventSource(stateID: .privateState), units: .pixel, wheelCount: 2, wheel1: Int32(-dy), wheel2: Int32(-dx), wheel3: 0) else { throw ComputerError("input_failed", "Could not create scroll event.") }
600-
let windowID = try required(p.windowId ?? snapshot.windowFrames.first(where: { $0.value.contains(position) })?.key, "windowId")
601-
let frame = try required(snapshot.windowFrames[windowID], "window frame")
602-
try requireForegroundTarget(pid: pid, windowID: windowID, point: position, frame: frame)
603-
event.location = position; event.post(tap: .cghidEventTap)
614+
try await coordinateAction { dispatch in
615+
let position = try point(p, app: app, snapshot: snapshot); let dx = p.deltaX ?? 0; let dy = p.deltaY ?? 0
616+
guard dx.isFinite, dy.isFinite, abs(dx) <= 10000, abs(dy) <= 10000 else { throw ComputerError("invalid_arguments", "Scroll deltas exceed bounds.") }
617+
guard let event = CGEvent(scrollWheelEvent2Source: CGEventSource(stateID: .privateState), units: .pixel, wheelCount: 2, wheel1: Int32(-dy), wheel2: Int32(-dx), wheel3: 0) else { throw ComputerError("input_failed", "Could not create scroll event.") }
618+
let windowID = try required(p.windowId ?? snapshot.windowFrames.first(where: { $0.value.contains(position) })?.key, "windowId")
619+
let frame = try required(snapshot.windowFrames[windowID], "window frame")
620+
try requireForegroundTarget(pid: pid, windowID: windowID, point: position, frame: frame)
621+
event.location = position; dispatch.willDispatch(); event.post(tap: .cghidEventTap)
622+
}
604623
case "drag":
605-
let start = try point(p, app: app, snapshot: snapshot); let end = try point(p, app: app, snapshot: snapshot, end: true)
606-
let windowID = try required(p.windowId, "windowId"); let frame = try required(snapshot.windowFrames[windowID], "window frame")
607-
try postMouse(pid: pid, type: .leftMouseDown, point: start, button: .left, windowID: windowID, frame: frame)
608-
var lastPoint = start
609-
defer { try? postMouse(pid: pid, type: .leftMouseUp, point: lastPoint, button: .left, windowID: windowID, frame: frame, release: true) }
610-
try await Task.sleep(for: .milliseconds(10))
611-
for step in 1...10 {
612-
try checkCancellation()
613-
let amount = CGFloat(step) / 10
614-
let position = CGPoint(x: start.x + (end.x - start.x) * amount, y: start.y + (end.y - start.y) * amount)
615-
try postMouse(pid: pid, type: .leftMouseDragged, point: position, button: .left, windowID: windowID, frame: frame)
616-
lastPoint = position
624+
try await coordinateAction { dispatch in
625+
let start = try point(p, app: app, snapshot: snapshot); let end = try point(p, app: app, snapshot: snapshot, end: true)
626+
let windowID = try required(p.windowId, "windowId"); let frame = try required(snapshot.windowFrames[windowID], "window frame")
627+
try postMouse(pid: pid, type: .leftMouseDown, point: start, button: .left, windowID: windowID, frame: frame, dispatch: dispatch)
628+
var lastPoint = start
629+
defer { try? postMouse(pid: pid, type: .leftMouseUp, point: lastPoint, button: .left, windowID: windowID, frame: frame, release: true, dispatch: dispatch) }
617630
try await Task.sleep(for: .milliseconds(10))
631+
for step in 1...10 {
632+
try checkCancellation()
633+
let amount = CGFloat(step) / 10
634+
let position = CGPoint(x: start.x + (end.x - start.x) * amount, y: start.y + (end.y - start.y) * amount)
635+
try postMouse(pid: pid, type: .leftMouseDragged, point: position, button: .left, windowID: windowID, frame: frame, dispatch: dispatch)
636+
lastPoint = position
637+
try await Task.sleep(for: .milliseconds(10))
638+
}
618639
}
619640
default: throw ComputerError("unknown_method", "Unknown method.")
620641
}

‎apps/desktop/native/computer-use/tests/NativeTests.swift‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import AppKit
66
catch let error as ComputerError { precondition(error.code == code) }
77
catch { fatalError("Unexpected \(error)") }
88
}
9-
static func main() throws {
9+
@MainActor static func main() async throws {
1010
let frame = CGRect(x: -1920, y: 120, width: 800, height: 600)
1111
try requireObservedFrame(frame, current: frame)
1212
expectError("stale_window") { try requireObservedFrame(frame, current: frame.offsetBy(dx: 1, dy: 0)) }
@@ -52,6 +52,23 @@ import AppKit
5252
let submitFocus = try keyMayChangeFocus("Enter"); precondition(!submitFocus)
5353
let searchFocus = try keyMayChangeFocus("Cmd+K"); precondition(searchFocus)
5454
let selectFocus = try keyMayChangeFocus("Cmd+A"); precondition(!selectFocus)
55+
do {
56+
try await coordinateAction { _ in throw ComputerError("foreground_required", "preflight") }
57+
fatalError("Expected pre-dispatch rejection")
58+
} catch let error as ComputerError {
59+
precondition(error.code == "foreground_required" && error.dispatchState == "not_started")
60+
}
61+
for code in ["foreground_required", "window_occluded", "cancelled"] {
62+
do {
63+
try await coordinateAction { dispatch in
64+
dispatch.willDispatch()
65+
throw ComputerError(code, "after mouse down", dispatchState: "not_started")
66+
}
67+
fatalError("Expected partial dispatch rejection")
68+
} catch let error as ComputerError {
69+
precondition(error.code == code && error.dispatchState == nil)
70+
}
71+
}
5572
cancellationRequested = 1
5673
expectError("cancelled") { try checkCancellation() }
5774
cancellationRequested = 0

‎apps/sim/lib/mothership/async-runs/repository.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1415,6 +1415,7 @@ export async function replaceTerminalAsyncToolCallResult(input: {
14151415
status: AsyncTerminalStatus
14161416
result: AsyncCompletionData | null
14171417
error: string | null
1418+
expectedResult?: AsyncCompletionData
14181419
}) {
14191420
return await withDbSpan(
14201421
TraceSpan.CopilotAsyncRunsMarkAsyncToolStatus,
@@ -1437,7 +1438,10 @@ export async function replaceTerminalAsyncToolCallResult(input: {
14371438
.where(
14381439
and(
14391440
eq(copilotAsyncToolCalls.toolCallId, input.toolCallId),
1440-
eq(copilotAsyncToolCalls.status, input.status)
1441+
eq(copilotAsyncToolCalls.status, input.status),
1442+
input.expectedResult !== undefined
1443+
? sql`${copilotAsyncToolCalls.result} = ${JSON.stringify(sanitizeValueForJsonb(input.expectedResult))}::jsonb`
1444+
: undefined
14411445
)
14421446
)
14431447
.returning()

‎apps/sim/lib/mothership/async-runs/tool-identity.integration.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,40 @@ describe.skipIf(!databaseUrl)('Copilot tool identity with PostgreSQL', () => {
208208
).resolves.toBeNull()
209209
})
210210

211+
it('allows only one projected receipt to replace the exact JSONB source across competing waiters', async () => {
212+
await createCurrentCalls()
213+
const source = { completion: 'sealed-source', context: { run: 'bound', user: 'bound' } }
214+
await completeAsyncToolCall({ toolCallId: firstId, status: 'completed', result: source })
215+
const receipts = [
216+
{ __sealedClientToolProjectionV1: 'first-authenticated-projection' },
217+
{ __sealedClientToolProjectionV1: 'second-authenticated-projection' },
218+
]
219+
const attempts = await Promise.all(
220+
receipts.map((result) =>
221+
replaceTerminalAsyncToolCallResult({
222+
toolCallId: firstId,
223+
status: 'completed',
224+
error: null,
225+
result,
226+
expectedResult: { context: { user: 'bound', run: 'bound' }, completion: 'sealed-source' },
227+
})
228+
)
229+
)
230+
expect(attempts.filter(Boolean)).toHaveLength(1)
231+
const winner = attempts.find((row) => row !== null)
232+
expect((await getAsyncToolCall(firstId))?.result).toEqual(winner?.result)
233+
expect(
234+
await replaceTerminalAsyncToolCallResult({
235+
toolCallId: firstId,
236+
status: 'completed',
237+
result: { success: true },
238+
error: null,
239+
expectedResult: source,
240+
})
241+
).toBeNull()
242+
expect((await getAsyncToolCall(firstId))?.result).toEqual(winner?.result)
243+
})
244+
211245
describe.skipIf(!redisUrl)('with actual Redis confirmation and permission channels', () => {
212246
it('wakes only the matching waiter and reads its durable terminal result', async () => {
213247
await createCurrentCalls()

‎apps/sim/lib/mothership/generated/computer-use.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export const ComputerUseSchema = z.union([
101101
.boolean()
102102
.optional()
103103
.describe(
104-
"Set true to explicitly activate this app before the keyboard batch. Defaults to false; editor, window, and geometry checks still apply.",
104+
"Explicitly bring this app to the foreground before the batch. Defaults to false; leave omitted for background input. An inactive app alone does not require activation. Do not set true when the user requires background operation. Editor, window, and geometry checks still apply.",
105105
),
106106
steps: z
107107
.array(InputSequenceStep)
@@ -159,7 +159,12 @@ const ComputerUseNodeSchema = z.strictObject({
159159
label: z.string().max(8192).optional(),
160160
value: z.string().max(32_000).optional(),
161161
enabled: z.boolean().optional(),
162-
focused: z.boolean().optional(),
162+
focused: z
163+
.boolean()
164+
.optional()
165+
.describe(
166+
"Accessibility focus within this app; does not imply the app is foreground or an item is selected.",
167+
),
163168
editable: z.boolean().optional(),
164169
placeholder: z.string().max(1024).optional(),
165170
actions: z.array(z.string().max(128)).max(128),
@@ -180,6 +185,12 @@ export const ComputerUseScreenshotSchema = z.strictObject({
180185
export const ComputerUseSnapshotSchema = z.strictObject({
181186
kind: z.literal("state"),
182187
bundleId: BundleId,
188+
isActive: z
189+
.boolean()
190+
.optional()
191+
.describe(
192+
"Whether this app was foreground when observed. Omitted means unknown; inactive apps may still support background controls.",
193+
),
183194
snapshotId: SnapshotId,
184195
windowId: WindowId,
185196
windows: z.array(ComputerUseWindowSchema).max(100),

‎apps/sim/lib/mothership/request/handlers/types.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { CopilotDegradedReason } from '@/lib/mothership/generated/trace-attribut
2020
import { recordDegraded } from '@/lib/mothership/request/metrics'
2121
import { markToolResultSeen } from '@/lib/mothership/request/sse-utils'
2222
import { setTerminalToolCallState } from '@/lib/mothership/request/tool-call-state'
23+
import { toolStatusOutput } from '@/lib/mothership/request/tools/tool-status-output'
2324
import type {
2425
ContentBlock,
2526
ExecutionContext,
@@ -299,7 +300,7 @@ export async function emitSyntheticToolResult(
299300
mode: MothershipStreamV1ToolMode.async,
300301
phase: MothershipStreamV1ToolPhase.result,
301302
success,
302-
output: resultPayload,
303+
output: toolStatusOutput(resultPayload),
303304
...(syntheticStatus ? { status: syntheticStatus } : {}),
304305
...(!success && completion?.message ? { error: completion.message } : {}),
305306
},

‎apps/sim/lib/mothership/request/tools/client-completion-seal.server.ts‎

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { generateId } from '@sim/utils/id'
22
import { isPlainRecord } from '@sim/utils/object'
33
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
4-
import type { AsyncCompletionData } from '@/lib/mothership/async-runs/lifecycle'
4+
import type {
5+
AsyncCompletionData,
6+
AsyncTerminalCompletionSnapshot,
7+
} from '@/lib/mothership/async-runs/lifecycle'
58
import {
69
isResolvedSecretTraceProvenanceV1,
710
type ResolvedSecretTraceProvenanceV1,
@@ -10,6 +13,7 @@ import {
1013

1114
export const SEALED_CLIENT_TOOL_COMPLETION_FIELD = '__sealedClientToolCompletionV1'
1215
export const SEALED_CLIENT_TOOL_CONTEXT_FIELD = '__sealedClientToolContextV1'
16+
export const SEALED_CLIENT_TOOL_PROJECTION_FIELD = '__sealedClientToolProjectionV1'
1317

1418
interface ClientToolBinding {
1519
toolCallId: string
@@ -47,7 +51,10 @@ type ReportUnsealFailure = (reason: ClientToolUnsealFailureReason) => void
4751
/** Reads a sealed record while exposing only the guard that refused it, never its contents. */
4852
async function readSealedRecord(
4953
value: unknown,
50-
field: typeof SEALED_CLIENT_TOOL_COMPLETION_FIELD | typeof SEALED_CLIENT_TOOL_CONTEXT_FIELD,
54+
field:
55+
| typeof SEALED_CLIENT_TOOL_COMPLETION_FIELD
56+
| typeof SEALED_CLIENT_TOOL_CONTEXT_FIELD
57+
| typeof SEALED_CLIENT_TOOL_PROJECTION_FIELD,
5158
reportFailure?: ReportUnsealFailure
5259
): Promise<Record<string, unknown> | null> {
5360
if (!isPlainRecord(value)) {
@@ -188,3 +195,48 @@ export async function unsealClientToolContext(
188195
provenance: context.provenance,
189196
}
190197
}
198+
199+
interface ProjectedClientToolCompletion extends ClientToolBinding {
200+
status: 'success' | 'error' | 'cancelled'
201+
message: string
202+
data: AsyncCompletionData
203+
}
204+
205+
/** Mint only after raw completion and provenance have passed model-safe projection. */
206+
export async function sealProjectedClientToolCompletion(
207+
content: ProjectedClientToolCompletion
208+
): Promise<Record<typeof SEALED_CLIENT_TOOL_PROJECTION_FIELD, string>> {
209+
const { encrypted } = await encryptSecret(
210+
JSON.stringify({
211+
purpose: 'projected-client-tool-completion-v1',
212+
...content,
213+
})
214+
)
215+
return { [SEALED_CLIENT_TOOL_PROJECTION_FIELD]: encrypted }
216+
}
217+
218+
/** An authenticated projected snapshot survives process handoff without replaying raw provenance. */
219+
export async function unsealProjectedClientToolCompletion(
220+
value: unknown,
221+
expected: ClientToolBinding & { status: AsyncTerminalCompletionSnapshot['status'] },
222+
reportFailure?: ReportUnsealFailure
223+
): Promise<AsyncTerminalCompletionSnapshot | null> {
224+
const content = await readSealedRecord(value, SEALED_CLIENT_TOOL_PROJECTION_FIELD, reportFailure)
225+
if (!content) return null
226+
if (!bindingMatches(content, expected) || content.status !== expected.status) {
227+
reportFailure?.('binding-mismatch')
228+
return null
229+
}
230+
if (
231+
content.purpose !== 'projected-client-tool-completion-v1' ||
232+
(content.status !== 'success' &&
233+
content.status !== 'error' &&
234+
content.status !== 'cancelled') ||
235+
typeof content.message !== 'string' ||
236+
!Object.hasOwn(content, 'data')
237+
) {
238+
reportFailure?.('invalid-content')
239+
return null
240+
}
241+
return { status: content.status, message: content.message, data: content.data }
242+
}

0 commit comments

Comments
 (0)