diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 0541a82e4..8730053d4 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -26,6 +26,33 @@ extension RunnerTests { } func execute(command: Command) throws -> Response { + if command.command == .status { + return executeStatus(command: command) + } + commandJournal.accept(command: command) + commandJournal.start(command: command) + do { + let response = try executeDispatched(command: command) + commandJournal.finish(command: command, response: response) + return response + } catch { + commandJournal.fail(command: command, error: error) + throw error + } + } + + private func executeStatus(command: Command) -> Response { + guard + let statusCommandId = command.statusCommandId? + .trimmingCharacters(in: .whitespacesAndNewlines), + !statusCommandId.isEmpty + else { + return Response(ok: false, error: ErrorPayload(message: "status requires statusCommandId")) + } + return Response(ok: true, data: commandJournal.status(commandId: statusCommandId)) + } + + private func executeDispatched(command: Command) throws -> Response { if Thread.isMainThread { return try executeOnMainSafely(command: command) } @@ -183,6 +210,8 @@ extension RunnerTests { } switch command.command { + case .status: + return executeStatus(command: command) case .shutdown: stopRecordingIfNeeded() return Response(ok: true, data: DataPayload(message: "shutdown")) diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift new file mode 100644 index 000000000..ac0c3f448 --- /dev/null +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift @@ -0,0 +1,140 @@ +import Foundation + +enum RunnerCommandLifecycleState: String { + case notAccepted + case accepted + case started + case completed + case failed +} + +struct RunnerCommandJournalEntry { + let commandId: String + let command: String + var state: RunnerCommandLifecycleState + var responseOk: Bool? + var responseJson: String? + var error: ErrorPayload? +} + +final class RunnerCommandJournal { + private let lock = NSLock() + private let maxEntries = 64 + private let maxResponseJsonBytes = 16 * 1024 + private var entries: [String: RunnerCommandJournalEntry] = [:] + private var order: [String] = [] + + func accept(command: Command) { + guard let commandId = normalizedCommandId(command.commandId) else { return } + lock.lock() + defer { lock.unlock() } + entries[commandId] = RunnerCommandJournalEntry( + commandId: commandId, + command: command.command.rawValue, + state: .accepted, + responseOk: nil, + responseJson: nil, + error: nil + ) + order.removeAll { $0 == commandId } + order.append(commandId) + pruneIfNeeded() + } + + func start(command: Command) { + update(command: command, state: .started, responseOk: nil, responseJson: nil, error: nil) + } + + func finish(command: Command, response: Response) { + update( + command: command, + state: response.ok ? .completed : .failed, + responseOk: response.ok, + responseJson: encodeResponseJson(response), + error: response.error + ) + } + + func fail(command: Command, error: Error) { + update( + command: command, + state: .failed, + responseOk: nil, + responseJson: nil, + error: ErrorPayload(message: error.localizedDescription) + ) + } + + func status(commandId: String) -> DataPayload { + guard let normalized = normalizedCommandId(commandId) else { + return DataPayload(lifecycleState: RunnerCommandLifecycleState.notAccepted.rawValue) + } + lock.lock() + let entry = entries[normalized] + lock.unlock() + guard let entry else { + return DataPayload( + commandId: normalized, + lifecycleState: RunnerCommandLifecycleState.notAccepted.rawValue + ) + } + return DataPayload( + commandId: entry.commandId, + lifecycleState: entry.state.rawValue, + lifecycleCommand: entry.command, + lifecycleResponseOk: entry.responseOk, + lifecycleResponseJson: entry.responseJson, + lifecycleErrorCode: entry.error?.code, + lifecycleErrorMessage: entry.error?.message, + lifecycleErrorHint: entry.error?.hint + ) + } + + private func update( + command: Command, + state: RunnerCommandLifecycleState, + responseOk: Bool?, + responseJson: String?, + error: ErrorPayload? + ) { + guard let commandId = normalizedCommandId(command.commandId) else { return } + lock.lock() + defer { lock.unlock() } + var entry = entries[commandId] ?? RunnerCommandJournalEntry( + commandId: commandId, + command: command.command.rawValue, + state: .accepted, + responseOk: nil, + responseJson: nil, + error: nil + ) + entry.state = state + entry.responseOk = responseOk + entry.responseJson = responseJson + entry.error = error + entries[commandId] = entry + order.removeAll { $0 == commandId } + order.append(commandId) + pruneIfNeeded() + } + + private func pruneIfNeeded() { + while order.count > maxEntries { + let removed = order.removeFirst() + entries.removeValue(forKey: removed) + } + } + + private func normalizedCommandId(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func encodeResponseJson(_ response: Response) -> String? { + guard response.data?.nodes == nil else { return nil } + guard let data = try? JSONEncoder().encode(response) else { return nil } + guard data.count <= maxResponseJsonBytes else { return nil } + return String(data: data, encoding: .utf8) + } +} diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index 6424b0363..5c3f4b448 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -30,6 +30,7 @@ enum CommandType: String, Codable { case transformGesture case recordStart case recordStop + case status case uptime case shutdown } @@ -91,6 +92,9 @@ extension CommandType { case .recordStop, .uptime, .shutdown: return CommandTraits(isInteraction: false, readOnly: .never, isLifecycle: true) + case .status: + return CommandTraits(isInteraction: false, readOnly: .always, isLifecycle: true) + // Normal preflight, not retried. // NOTE: mouseClick stays non-interaction for now — it is macOS-only and the foreground // guard interacts with bespoke macOS activation, so classifying it needs a macOS smoke @@ -104,6 +108,8 @@ extension CommandType { struct Command: Codable { let command: CommandType + let commandId: String? + let statusCommandId: String? let appBundleId: String? let text: String? let selectorKey: String? @@ -171,6 +177,14 @@ struct DataPayload: Codable { let referenceWidth: Double? let referenceHeight: Double? let currentUptimeMs: Double? + let commandId: String? + let lifecycleState: String? + let lifecycleCommand: String? + let lifecycleResponseOk: Bool? + let lifecycleResponseJson: String? + let lifecycleErrorCode: String? + let lifecycleErrorMessage: String? + let lifecycleErrorHint: String? let visible: Bool? let wasVisible: Bool? let dismissed: Bool? @@ -192,6 +206,14 @@ struct DataPayload: Codable { referenceWidth: Double? = nil, referenceHeight: Double? = nil, currentUptimeMs: Double? = nil, + commandId: String? = nil, + lifecycleState: String? = nil, + lifecycleCommand: String? = nil, + lifecycleResponseOk: Bool? = nil, + lifecycleResponseJson: String? = nil, + lifecycleErrorCode: String? = nil, + lifecycleErrorMessage: String? = nil, + lifecycleErrorHint: String? = nil, visible: Bool? = nil, wasVisible: Bool? = nil, dismissed: Bool? = nil, @@ -212,6 +234,14 @@ struct DataPayload: Codable { self.referenceWidth = referenceWidth self.referenceHeight = referenceHeight self.currentUptimeMs = currentUptimeMs + self.commandId = commandId + self.lifecycleState = lifecycleState + self.lifecycleCommand = lifecycleCommand + self.lifecycleResponseOk = lifecycleResponseOk + self.lifecycleResponseJson = lifecycleResponseJson + self.lifecycleErrorCode = lifecycleErrorCode + self.lifecycleErrorMessage = lifecycleErrorMessage + self.lifecycleErrorHint = lifecycleErrorHint self.visible = visible self.wasVisible = wasVisible self.dismissed = dismissed diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index 0155b245c..810ec413d 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -53,6 +53,7 @@ final class RunnerTests: XCTestCase { var needsPostSnapshotInteractionDelay = false var needsFirstInteractionDelay = false var activeRecording: ScreenRecorder? + let commandJournal = RunnerCommandJournal() let interactiveTypes: Set = [ .button, .cell, diff --git a/src/platforms/ios/__tests__/runner-client.test.ts b/src/platforms/ios/__tests__/runner-client.test.ts index aaef32893..79b732e01 100644 --- a/src/platforms/ios/__tests__/runner-client.test.ts +++ b/src/platforms/ios/__tests__/runner-client.test.ts @@ -32,6 +32,7 @@ vi.mock('../runner-macos-products.ts', async () => { import type { DeviceInfo } from '../../../utils/device.ts'; import { AppError } from '../../../utils/errors.ts'; import type { RunnerCommand } from '../runner-contract.ts'; +import { withRunnerCommandId } from '../runner-contract.ts'; import { assertSafeDerivedCleanup, isRetryableRunnerError, @@ -159,6 +160,7 @@ const runnerProtocolCommandFixtures: Record { + const command = withRunnerCommandId({ command: 'uptime', commandId: ' ' }); + + assert.match(command.commandId ?? '', /^runner-/); +}); + +test('withRunnerCommandId preserves existing command ids', () => { + const command = withRunnerCommandId({ command: 'uptime', commandId: 'runner-existing' }); + + assert.deepEqual(command, { command: 'uptime', commandId: 'runner-existing' }); +}); + +test('withRunnerCommandId does not add command ids to status probes', () => { + const command = withRunnerCommandId({ + command: 'status', + statusCommandId: 'runner-command-1', + }); + + assert.deepEqual(command, { command: 'status', statusCommandId: 'runner-command-1' }); +}); + test('resolveRunnerDestination uses device destination for physical devices', () => { assert.equal(resolveRunnerDestination(iosDevice), 'platform=iOS,id=00008110-000E12341234002E'); }); diff --git a/src/platforms/ios/__tests__/runner-session.test.ts b/src/platforms/ios/__tests__/runner-session.test.ts index bfe3cc744..3ad170e1d 100644 --- a/src/platforms/ios/__tests__/runner-session.test.ts +++ b/src/platforms/ios/__tests__/runner-session.test.ts @@ -128,13 +128,48 @@ test('runner session executes read-only commands without uptime preflight', asyn assert.deepEqual(result, { nodes: [], truncated: false }); assert.equal(session.ready, true); assert.equal(mockWaitForRunner.mock.calls.length, 1); - assert.deepEqual(mockWaitForRunner.mock.calls[0]?.[2], { + assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'snapshot', appBundleId: 'com.example.demo', }); assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 0); }); +test('runner session executes status command as read-only lifecycle command', async () => { + const session = makeRunnerSession({ ready: true }); + mockWaitForRunner.mockResolvedValueOnce( + runnerResponse({ + commandId: 'runner-command-1', + lifecycleState: 'completed', + lifecycleResponseOk: true, + }), + ); + + const result = await executeRunnerCommandWithSession( + IOS_SIMULATOR, + session, + { command: 'status', statusCommandId: 'runner-command-1' }, + '/tmp/runner.log', + 30_000, + ); + + assert.deepEqual(result, { + commandId: 'runner-command-1', + lifecycleState: 'completed', + lifecycleResponseOk: true, + }); + assert.equal(mockWaitForRunner.mock.calls.length, 1); + assertRunnerCommand( + mockWaitForRunner.mock.calls[0]?.[2], + { + command: 'status', + statusCommandId: 'runner-command-1', + }, + { commandId: false }, + ); + assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 0); +}); + test('runner session probes readiness before mutating commands', async () => { const session = makeRunnerSession({ ready: false }); mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); @@ -151,9 +186,9 @@ test('runner session probes readiness before mutating commands', async () => { assert.deepEqual(result, { tapped: true }); assert.equal(session.ready, true); assert.equal(mockWaitForRunner.mock.calls.length, 1); - assert.deepEqual(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); + assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1); - assert.deepEqual(mockSendRunnerCommandOnce.mock.calls[0]?.[2], { + assertRunnerCommand(mockSendRunnerCommandOnce.mock.calls[0]?.[2], { command: 'tap', x: 120, y: 240, @@ -239,7 +274,7 @@ test('runner session keeps readiness preflight for tap commands when ready but n assert.deepEqual(result, { tapped: true }); assert.equal(mockWaitForRunner.mock.calls.length, 1); - assert.deepEqual(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); + assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1); }); @@ -261,7 +296,7 @@ test('runner session keeps readiness preflight for tap commands when marked read assert.deepEqual(result, { tapped: true }); assert.equal(mockWaitForRunner.mock.calls.length, 1); - assert.deepEqual(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); + assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1); }); @@ -280,7 +315,7 @@ test('runner session keeps readiness preflight for non-tap mutating commands whe assert.deepEqual(result, { pressed: true }); assert.equal(mockWaitForRunner.mock.calls.length, 1); - assert.deepEqual(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); + assertRunnerCommand(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1); }); @@ -371,7 +406,7 @@ test('runner session stop sends shutdown, cleans temporary runner files, and rel mockIsProcessAlive.mockReturnValue(false); await stopRunnerSession(session); - assert.deepEqual(mockWaitForRunner.mock.calls.at(-1)?.[2], { command: 'shutdown' }); + assertRunnerCommand(mockWaitForRunner.mock.calls.at(-1)?.[2], { command: 'shutdown' }); assert.deepEqual(mockCleanupTempFile.mock.calls, [ ['/tmp/session-runner.xctestrun'], ['/tmp/session-runner.json'], @@ -453,3 +488,24 @@ function runnerResponse(data: Record): Response { function runnerError(error: { code: string; message: string }): Response { return new Response(JSON.stringify({ ok: false, error })); } + +function assertRunnerCommand( + actual: unknown, + expected: Record, + options: { commandId?: boolean } = {}, +): asserts actual is Record { + assert.equal(typeof actual, 'object'); + assert.notEqual(actual, null); + const command = actual as Record; + const commandId = command.commandId; + if (options.commandId === false) { + assert.equal(commandId, undefined); + assert.deepEqual(command, expected); + return; + } + if (typeof commandId !== 'string') { + assert.fail('expected runner commandId'); + } + assert.match(commandId, /^runner-/); + assert.deepEqual({ ...command, commandId: undefined }, { ...expected, commandId: undefined }); +} diff --git a/src/platforms/ios/runner-client.ts b/src/platforms/ios/runner-client.ts index b1e788d89..cc8b52125 100644 --- a/src/platforms/ios/runner-client.ts +++ b/src/platforms/ios/runner-client.ts @@ -18,6 +18,7 @@ import { isReadOnlyRunnerCommand, isRetryableRunnerError, shouldRetryRunnerConnectError, + withRunnerCommandId, type RunnerCommand, } from './runner-contract.ts'; import { @@ -43,17 +44,18 @@ export async function runIosRunnerCommand( ): Promise> { validateRunnerDevice(device); assertRunnerRequestActive(options.requestId); + const runnerCommand = withRunnerCommandId(command); const provider = resolveAppleRunnerProvider( device, createLocalAppleRunnerProvider(executeRunnerCommand), undefined, { requestId: options.requestId }, ); - if (isReadOnlyRunnerCommand(command.command)) { + if (isReadOnlyRunnerCommand(runnerCommand.command)) { return withRetry( () => { assertRunnerRequestActive(options.requestId); - return provider.runCommand(device, command, options); + return provider.runCommand(device, runnerCommand, options); }, { shouldRetry: (error) => { @@ -63,7 +65,7 @@ export async function runIosRunnerCommand( }, ); } - return provider.runCommand(device, command, options); + return provider.runCommand(device, runnerCommand, options); } export function prewarmIosRunnerSession( diff --git a/src/platforms/ios/runner-contract.ts b/src/platforms/ios/runner-contract.ts index c7958a099..e6af965f3 100644 --- a/src/platforms/ios/runner-contract.ts +++ b/src/platforms/ios/runner-contract.ts @@ -1,3 +1,4 @@ +import crypto from 'node:crypto'; import { AppError } from '../../utils/errors.ts'; import type { ClickButton } from '../../core/click-button.ts'; import type { DeviceRotation } from '../../core/device-rotation.ts'; @@ -39,8 +40,11 @@ export type RunnerCommand = { | 'pinch' | 'recordStart' | 'recordStop' + | 'status' | 'uptime' | 'shutdown'; + commandId?: string; + statusCommandId?: string; appBundleId?: string; text?: string; selectorKey?: 'id' | 'label' | 'text' | 'value'; @@ -199,10 +203,21 @@ export function isReadOnlyRunnerCommand(command: RunnerCommand['command']): bool command === 'querySelector' || command === 'readText' || command === 'alert' || + command === 'status' || command === 'uptime' ); } +export function withRunnerCommandId(command: RunnerCommand): RunnerCommand { + if (command.command === 'status') return command; + if (command.commandId?.trim()) return command; + return { ...command, commandId: createRunnerCommandId() }; +} + +function createRunnerCommandId(): string { + return `runner-${crypto.randomUUID()}`; +} + export function assertRunnerRequestActive(requestId: string | undefined): void { if (!isRequestCanceled(requestId)) return; throw createRequestCanceledError(); diff --git a/src/platforms/ios/runner-session.ts b/src/platforms/ios/runner-session.ts index 40099971d..239970ea7 100644 --- a/src/platforms/ios/runner-session.ts +++ b/src/platforms/ios/runner-session.ts @@ -25,7 +25,11 @@ import { resolveRunnerMaxConcurrentDestinationsFlag, runnerPrepProcesses, } from './runner-xctestrun.ts'; -import { isReadOnlyRunnerCommand, type RunnerCommand } from './runner-contract.ts'; +import { + isReadOnlyRunnerCommand, + withRunnerCommandId, + type RunnerCommand, +} from './runner-contract.ts'; import type { RunnerSession } from './runner-session-types.ts'; export type { RunnerSession } from './runner-session-types.ts'; @@ -259,9 +263,9 @@ async function stopRunnerSessionInternal( await waitForRunner( session.device, session.port, - { + withRunnerCommandId({ command: 'shutdown', - } as RunnerCommand, + } as RunnerCommand), undefined, RUNNER_SHUTDOWN_TIMEOUT_MS, ); @@ -432,19 +436,34 @@ export async function executeRunnerCommandWithSession( signal?: AbortSignal, ): Promise> { emitRunnerStartupTimings(session, command.command); - const readOnlyCommand = isReadOnlyRunnerCommand(command.command); + const runnerCommand = withRunnerCommandId(command); + const readOnlyCommand = isReadOnlyRunnerCommand(runnerCommand.command); if (readOnlyCommand) { const response = await withDiagnosticTimer( 'ios_runner_command_send', async () => - await waitForRunner(device, session.port, command, logPath, timeoutMs, session, signal), - { command: command.command, readOnly: true, sessionReady: session.ready, timeoutMs }, + await waitForRunner( + device, + session.port, + runnerCommand, + logPath, + timeoutMs, + session, + signal, + ), + { + command: runnerCommand.command, + commandId: runnerCommand.commandId, + readOnly: true, + sessionReady: session.ready, + timeoutMs, + }, ); return await parseRunnerResponse(response, session, logPath); } const deadline = Deadline.fromTimeoutMs(timeoutMs); - const shouldPreflight = shouldPreflightMutatingRunnerCommand(session, command); + const shouldPreflight = shouldPreflightMutatingRunnerCommand(session, runnerCommand); if (shouldPreflight) { const readinessTimeoutMs = session.ready ? Math.min(RUNNER_READY_PREFLIGHT_TIMEOUT_MS, deadline.remainingMs()) @@ -456,13 +475,18 @@ export async function executeRunnerCommandWithSession( await waitForRunner( device, session.port, - { command: 'uptime' }, + withRunnerCommandId({ command: 'uptime' }), logPath, readinessTimeoutMs, session, signal, ), - { command: command.command, sessionReady: session.ready, timeoutMs: readinessTimeoutMs }, + { + command: runnerCommand.command, + commandId: runnerCommand.commandId, + sessionReady: session.ready, + timeoutMs: readinessTimeoutMs, + }, ); await parseRunnerResponse(readinessResponse, session, logPath); } catch (error) { @@ -474,6 +498,7 @@ export async function executeRunnerCommandWithSession( phase: 'ios_runner_readiness_preflight_skipped', data: { command: command.command, + commandId: runnerCommand.commandId, lastSuccessfulRunnerResponseAgeMs: session.lastSuccessfulRunnerResponseAtMs === undefined ? undefined @@ -488,8 +513,9 @@ export async function executeRunnerCommandWithSession( } const response = await withDiagnosticTimer( 'ios_runner_command_send', - async () => await sendRunnerCommandOnce(device, session.port, command, remainingMs, signal), - { command: command.command }, + async () => + await sendRunnerCommandOnce(device, session.port, runnerCommand, remainingMs, signal), + { command: runnerCommand.command, commandId: runnerCommand.commandId }, ); return await parseRunnerResponse(response, session, logPath); } diff --git a/test/integration/provider-scenarios/providers.ts b/test/integration/provider-scenarios/providers.ts index 84a9dd83b..262911849 100644 --- a/test/integration/provider-scenarios/providers.ts +++ b/test/integration/provider-scenarios/providers.ts @@ -1,4 +1,5 @@ import type { AppleRunnerProvider } from '../../../src/platforms/ios/runner-provider.ts'; +import type { RunnerCommand } from '../../../src/platforms/ios/runner-contract.ts'; import type { AppleMacOsHostProvider, ApplePlistProvider, @@ -24,13 +25,20 @@ export function createAppleRunnerProviderFromTranscript( ): AppleRunnerProvider { return { runCommand: async (device, command) => - transcript.next(`${commandPrefix}.${command.command}`, command, { + transcript.next(`${commandPrefix}.${command.command}`, stripRunnerCommandId(command), { deviceId: device.id, platform: device.platform, }) as Record, }; } +function stripRunnerCommandId(command: RunnerCommand): RunnerCommand { + if (command.commandId === undefined) return command; + const normalized = { ...command }; + delete normalized.commandId; + return normalized; +} + export function createRecordingAppleToolProvider(handlers: RecordingAppleToolHandlers = {}): { provider: AppleToolProvider; calls: FlatToolCall[];