Skip to content

fix(dart-core): route only function misses to the agent, with the resolved catalogId - #3739

Open
nan-yu wants to merge 11 commits into
fix/agent-function-fallback-routingfrom
fix/dart-agent-function-routing
Open

nan-yu wants to merge 11 commits into
fix/agent-function-fallback-routingfrom
fix/dart-agent-function-routing

Conversation

@nan-yu

@nan-yu nan-yu commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #3735. Review only the top commit (fix(dart-core): ...).
Part of #3536.

Description

Dart already routes a v1.0 function call it cannot run to the agent with callAgentFunction. This PR aligns it with the routing rules in #3735 and the clarified protocol doc:

  1. Only a function miss is routed. Before, any A2uiCatalogResolutionError went to the agent, including an unknown catalogId and a surface with no default catalog. Those are now reported as CATALOG_ERROR and are not sent. A call is routed only when its catalog resolved and the function is missing from it.

  2. The outbound call names the resolved catalog. Before, it carried the payload's catalogId, which is empty when the binding relies on the surface default. It now always carries the catalog the call resolved to, as agreed on [v1.0][spec] No way to declare agent-hosted functions #3536 (comment). In-flight sharing is keyed on the resolved catalog, so a call with and without the default catalogId shares one request and one pending state.

  3. The caller is installed only when the agent is reachable. SurfaceModel.callAgentFunction is now set only on v1.0 surfaces of a processor with an outboundListener. Before, it was always set, so a processor without a listener routed calls and failed with NO_LISTENER.

  4. A call waits for a pending agent argument. Before, a function whose argument read an agent call still in flight ran with that argument null (a local function), or was sent with it null (an agent call). It now waits for the argument to settle, in bindings and when a value is resolved once. A failed argument stops the enclosing call, and the failure is reported once.

  5. Pending aliases survive a full table. When the alias table is full, the oldest alias of a settled call is evicted instead of clearing the table.

Schema-only functions do not apply to Dart: surface catalogs are typed Catalog<T, FunctionImplementation>, so every function on a surface has an implementation.

Testing

  1. agent_function_fallback_test.dart: an unknown catalogId and a missing default catalog are CATALOG_ERROR and nothing is sent (binding and evaluateFunctionCall); an explicit catalogId is carried; the default catalog id is filled in (binding and action); calls with and without the default catalogId share one request; a @ system function is never sent.
  2. processor_rpc_test.dart: an end-to-end binding sends {"@call", "catalogId", "args"} and takes the agent's value; no listener means no caller; a v0.9 surface has no caller.
  3. dart analyze is clean, dart test for a2ui_core passes (1897 passed, 7 skipped), and flutter test for a2ui_flutter passes (382).

…the resolved catalogId

A function call whose catalog cannot be resolved is now a CATALOG_ERROR and
is not sent as callAgentFunction. The outbound call always names the catalog
the call resolved to, and calls that differ only in naming the default
catalog share one request. MessageProcessor installs the agent caller only
on v1.0 surfaces and only when it has an outbound listener.

Signed-off-by: Nan Yu <nanyu@google.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the agent function fallback routing to follow the v1.0 specification. Key changes include reporting unresolved catalogs as CATALOG_ERROR instead of sending them to the agent, ensuring outbound calls name the resolved catalog, and sharing requests for pending calls that only differ by the default catalog name. Additionally, MessageProcessor now only provides callAgentFunction for v1.0 surfaces with an outbound listener. Feedback on these changes suggests improving the cache eviction strategy in _AgentCallCache to evict only the oldest alias rather than clearing the entire map, which avoids prematurely discarding active pending aliases.

…ble is full

Evict the oldest alias of a settled call instead of clearing the whole
table, so isPendingAgentCall keeps finding calls still in flight.

Signed-off-by: Nan Yu <nanyu@google.com>
A function call whose argument reads an agent call that is still pending
now waits for it instead of running with a null argument, in bindings and
when a value is resolved once. An outer agent call is sent only after its
agent arguments settle, and a failed argument stops the enclosing call
without reporting the failure twice.

Signed-off-by: Nan Yu <nanyu@google.com>
… fallback edge cases

Signed-off-by: Nan Yu <nanyu@google.com>
…ing' into HEAD

Signed-off-by: Nan Yu <nanyu@google.com>
…avoid duplicate INVALID_ACTION

Signed-off-by: Nan Yu <nanyu@google.com>
…ing' into fix/dart-agent-function-routing

Signed-off-by: Nan Yu <nanyu@google.com>

# Conflicts:
#	dart/a2ui_core/lib/src/resolution/generic_binder.dart

@gspencergoog gspencergoog left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change aligns v1.0 agent function fallback routing in dart/a2ui_core (data_context.dart, generic_binder.dart, processor.dart, and rpc_handler.dart) with the v1.0 specification. Unresolvable catalogs now report CATALOG_ERROR locally, outbound calls carry the resolved catalogId, nested expressions await pending agent arguments, and surface deletion cancels pending RPCs.

The v1.0 catalog routing and signal dependency wiring are solid, but the new imperative action path in resolveActionAsync introduces two high-severity defects: pre-v1.0 failed action calls leak the private _failedArg sentinel into resolved maps and arguments, and _resolveActionFieldsAsync starts all field Futures eagerly before awaiting (leaking unhandled rejections on early returns). Several medium-severity issues also remain around unreachable _AgentCallCache._aliases code, synchronous return contracts, post-await surface deletion guards, RpcErrorCode.cancelled error reporting, and missing changelog notes.

🤖 Agent-generated

Comment on lines +1175 to +1179
return _evaluateActionCall(
_asStringKeyedMap(value as Map),
depth,
reportTopLevelAgentError: true,
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On pre-v1.0 surfaces, _resolveActionValue calls _evaluateActionCall with reportTopLevelAgentError: true, returning _failedArg on failure. Because _containsFailedArg checks require atLeastV10, that private Object() sentinel leaks into resolved context fields and outer arguments. Failed v0.9.1 action calls crash jsonEncode on _failedArg, pass Object() to outer functions, or trigger spurious INVALID_ACTION errors.

Suggested change
return _evaluateActionCall(
_asStringKeyedMap(value as Map),
depth,
reportTopLevelAgentError: true,
);
return _evaluateActionCall(
_asStringKeyedMap(value as Map),
depth,
reportTopLevelAgentError: true,
).then((res) => identical(res, _failedArg) ? null : res);

🤖 Agent-generated

Comment on lines +1306 to +1376
FutureOr<Map<String, dynamic>?> _resolveActionFieldsAsync(
Map<String, dynamic> map, {
void Function()? onExpressionFailed,
}) {
final result = Map<String, dynamic>.from(map);
final Object? ctx = result['context'];
final rawContext = <String, Object?>{};
var hasFuture = false;
if (ctx is Map) {
for (final MapEntry<Object?, Object?> e in ctx.entries) {
final Object? rawVal = _resolveActionValue(
e.value,
0,
keepFailed: atLeastV10,
);
if (rawVal is Future) {
hasFuture = true;
} else if (atLeastV10 && _containsFailedArg(rawVal)) {
onExpressionFailed?.call();
return null;
}
rawContext[e.key.toString()] = rawVal;
}
}
final bool hasUserMessage = result.containsKey('userMessage');
Object? rawMsg;
if (hasUserMessage) {
rawMsg = _resolveActionValue(
result['userMessage'],
0,
keepFailed: atLeastV10,
);
if (rawMsg is Future) {
hasFuture = true;
} else {
if (atLeastV10 && _containsFailedArg(rawMsg)) {
onExpressionFailed?.call();
return null;
}
if (rawMsg != null && rawMsg is! String) return null;
}
}
if (!hasFuture) {
result['context'] = rawContext;
if (hasUserMessage) result['userMessage'] = rawMsg;
return result;
}
return () async {
final resolvedContext = <String, Object?>{};
for (final MapEntry<String, Object?> e in rawContext.entries) {
final Object? rawVal = e.value;
final Object? val = rawVal is Future ? await rawVal : rawVal;
if (atLeastV10 && _containsFailedArg(val)) {
onExpressionFailed?.call();
return null;
}
resolvedContext[e.key] = val;
}
result['context'] = resolvedContext;
if (hasUserMessage) {
final Object? msg = rawMsg is Future ? await rawMsg : rawMsg;
if (atLeastV10 && _containsFailedArg(msg)) {
onExpressionFailed?.call();
return null;
}
if (msg != null && msg is! String) return null;
result['userMessage'] = msg;
}
return result;
}();
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_resolveActionFieldsAsync starts all context and userMessage Futures synchronously before awaiting any result (raised by both reviewers). Early returns on a failed entry, non-string userMessage, or exception abandon sibling Futures without error handlers. Callers without onError hit unhandled async Future exceptions, and invalid actions still dispatch later side-effecting agent calls.

Suggested change
FutureOr<Map<String, dynamic>?> _resolveActionFieldsAsync(
Map<String, dynamic> map, {
void Function()? onExpressionFailed,
}) {
final result = Map<String, dynamic>.from(map);
final Object? ctx = result['context'];
final rawContext = <String, Object?>{};
var hasFuture = false;
if (ctx is Map) {
for (final MapEntry<Object?, Object?> e in ctx.entries) {
final Object? rawVal = _resolveActionValue(
e.value,
0,
keepFailed: atLeastV10,
);
if (rawVal is Future) {
hasFuture = true;
} else if (atLeastV10 && _containsFailedArg(rawVal)) {
onExpressionFailed?.call();
return null;
}
rawContext[e.key.toString()] = rawVal;
}
}
final bool hasUserMessage = result.containsKey('userMessage');
Object? rawMsg;
if (hasUserMessage) {
rawMsg = _resolveActionValue(
result['userMessage'],
0,
keepFailed: atLeastV10,
);
if (rawMsg is Future) {
hasFuture = true;
} else {
if (atLeastV10 && _containsFailedArg(rawMsg)) {
onExpressionFailed?.call();
return null;
}
if (rawMsg != null && rawMsg is! String) return null;
}
}
if (!hasFuture) {
result['context'] = rawContext;
if (hasUserMessage) result['userMessage'] = rawMsg;
return result;
}
return () async {
final resolvedContext = <String, Object?>{};
for (final MapEntry<String, Object?> e in rawContext.entries) {
final Object? rawVal = e.value;
final Object? val = rawVal is Future ? await rawVal : rawVal;
if (atLeastV10 && _containsFailedArg(val)) {
onExpressionFailed?.call();
return null;
}
resolvedContext[e.key] = val;
}
result['context'] = resolvedContext;
if (hasUserMessage) {
final Object? msg = rawMsg is Future ? await rawMsg : rawMsg;
if (atLeastV10 && _containsFailedArg(msg)) {
onExpressionFailed?.call();
return null;
}
if (msg != null && msg is! String) return null;
result['userMessage'] = msg;
}
return result;
}();
}
FutureOr<Map<String, dynamic>?> _resolveActionFieldsAsync(
Map<String, dynamic> map, {
void Function()? onExpressionFailed,
}) {
final result = Map<String, dynamic>.from(map);
final Object? ctx = result['context'];
final List<MapEntry<Object?, Object?>> entries =
ctx is Map ? ctx.entries.toList() : const [];
final resolved = <String, Object?>{};
final bool hasMsg = result.containsKey('userMessage');
bool checkFailed(Object? v) {
if (!atLeastV10 || !_containsFailedArg(v)) return false;
onExpressionFailed?.call();
return true;
}
Future<Map<String, dynamic>?> finishMsg(Future<dynamic> pending) async {
final Object? msg = await pending;
if (checkFailed(msg) || (msg != null && msg is! String)) return null;
result['context'] = resolved;
result['userMessage'] = msg;
return result;
}
Future<Map<String, dynamic>?> finishCtx(int i, Future<dynamic> cur) async {
for (var j = i; j < entries.length; j++) {
final Object? raw = j == i
? cur
: _resolveActionValue(entries[j].value, 0, keepFailed: atLeastV10);
final Object? val = raw is Future ? await raw : raw;
if (checkFailed(val)) return null;
resolved[entries[j].key.toString()] = val;
}
if (hasMsg) {
final Object? rawMsg = _resolveActionValue(
result['userMessage'],
0,
keepFailed: atLeastV10,
);
if (rawMsg is Future) return finishMsg(rawMsg);
if (checkFailed(rawMsg) || (rawMsg != null && rawMsg is! String)) {
return null;
}
result['userMessage'] = rawMsg;
}
result['context'] = resolved;
return result;
}
for (var i = 0; i < entries.length; i++) {
final Object? raw =
_resolveActionValue(entries[i].value, 0, keepFailed: atLeastV10);
if (raw is Future) return finishCtx(i, raw);
if (checkFailed(raw)) return null;
resolved[entries[i].key.toString()] = raw;
}
if (hasMsg) {
final Object? rawMsg = _resolveActionValue(
result['userMessage'],
0,
keepFailed: atLeastV10,
);
if (rawMsg is Future) return finishMsg(rawMsg);
if (checkFailed(rawMsg) || (rawMsg != null && rawMsg is! String)) {
return null;
}
result['userMessage'] = rawMsg;
}
result['context'] = resolved;
return result;
}

🤖 Agent-generated

Comment on lines +1508 to +1550
_AgentCall? lookup(
FunctionCall call,
String? resolvedCatalogId,
Map<String, dynamic> args,
) {
if (call.catalogId == null && resolvedCatalogId != null) {
defaultCatalogId ??= resolvedCatalogId;
}
final String writtenKey = DataContext._agentCallKey(
call.call,
call.catalogId ?? defaultCatalogId,
args,
);
if (resolvedCatalogId == null) {
return entries[_aliases[writtenKey] ?? writtenKey];
}
final String key =
DataContext._agentCallKey(call.call, resolvedCatalogId, args);
if (writtenKey != key) {
if (!_aliases.containsKey(writtenKey) && _aliases.length >= _maxEntries) {
_evictAlias();
}
_aliases[writtenKey] = key;
}
return entries[key];
}

/// Removes one alias to make room for another.
///
/// Prefers the oldest alias whose call is no longer pending, so that
/// [DataContext.isPendingAgentCall] keeps finding calls still in flight.
/// Only when every alias points at a pending call is the oldest one
/// removed.
void _evictAlias() {
for (final MapEntry<String, String> alias in _aliases.entries) {
if (entries[alias.value]?.pending != true) {
_aliases.remove(alias.key);
return;
}
}
final String? oldest = _aliases.keys.firstOrNull;
if (oldest != null) _aliases.remove(oldest);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although _evictAlias was added for prior thread r4408126301, lookup assigns defaultCatalogId before computing writtenKey (call.catalogId ?? defaultCatalogId). On every SurfaceModel and single-catalog DataContext, writtenKey equals key, leaving _aliases empty across all 301 calls in agent_function_fallback_test.dart:660. Unreachable alias-eviction logic leaves dead code in _AgentCallCache and makes the 300-call alias test pass solely through evictIfFull().

-  final Map<String, String> _aliases = {};
...
   bool hasFailed(FunctionCall call, Map<String, dynamic> args) {
     final String writtenKey = DataContext._agentCallKey(
       call.call,
       call.catalogId ?? defaultCatalogId,
       args,
     );
-    return failed.contains(_aliases[writtenKey] ?? writtenKey);
+    return failed.contains(writtenKey);
   }
...
   _AgentCall? lookup(
     FunctionCall call,
     String? resolvedCatalogId,
     Map<String, dynamic> args,
   ) {
     if (call.catalogId == null && resolvedCatalogId != null) {
       defaultCatalogId ??= resolvedCatalogId;
     }
-    final String writtenKey = DataContext._agentCallKey(
+    final String key = DataContext._agentCallKey(
       call.call,
-      call.catalogId ?? defaultCatalogId,
+      resolvedCatalogId ?? call.catalogId ?? defaultCatalogId,
       args,
     );
-    if (resolvedCatalogId == null) {
-      return entries[_aliases[writtenKey] ?? writtenKey];
-    }
-    final String key =
-        DataContext._agentCallKey(call.call, resolvedCatalogId, args);
-    if (writtenKey != key) {
-      if (!_aliases.containsKey(writtenKey) && _aliases.length >= _maxEntries) {
-        _evictAlias();
-      }
-      _aliases[writtenKey] = key;
-    }
     return entries[key];
   }
-
-  void _evictAlias() {
-    ...
-  }

🤖 Agent-generated

Comment on lines +1153 to +1167
return () async {
final result = <String, dynamic>{};
for (final MapEntry<String, dynamic> entry in stringMap.entries) {
final String keyStr = entry.key;
final String unescapedKey =
keyStr.startsWith('@@') ? keyStr.substring(1) : keyStr;
final Object? val = _resolveActionValue(
entry.value,
depth + 1,
keepFailed: keepFailed,
);
result[unescapedKey] = val is Future ? await val : val;
}
return result;
}();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveActionAsync promises synchronous returns without nested Futures, yet _resolveActionValue wraps every nested Map and List containing bindings in async closures (raised by both reviewers). It also leaves [onExpressionFailed] undocumented on the public DataContext API. Synchronous @path bindings inside nested action maps or lists still return a Future, deferring GenericBinder dispatch across a microtask.

Suggested change
return () async {
final result = <String, dynamic>{};
for (final MapEntry<String, dynamic> entry in stringMap.entries) {
final String keyStr = entry.key;
final String unescapedKey =
keyStr.startsWith('@@') ? keyStr.substring(1) : keyStr;
final Object? val = _resolveActionValue(
entry.value,
depth + 1,
keepFailed: keepFailed,
);
result[unescapedKey] = val is Future ? await val : val;
}
return result;
}();
final List<MapEntry<String, dynamic>> entries =
stringMap.entries.toList();
final result = <String, dynamic>{};
for (var i = 0; i < entries.length; i++) {
final String keyStr = entries[i].key;
final String outKey =
keyStr.startsWith('@@') ? keyStr.substring(1) : keyStr;
final Object? val = _resolveActionValue(
entries[i].value,
depth + 1,
keepFailed: keepFailed,
);
if (val is Future) {
return () async {
result[outKey] = await val;
for (var j = i + 1; j < entries.length; j++) {
final String k = entries[j].key;
final Object? next = _resolveActionValue(
entries[j].value,
depth + 1,
keepFailed: keepFailed,
);
result[k.startsWith('@@') ? k.substring(1) : k] =
next is Future ? await next : next;
}
return result;
}();
}
result[outKey] = val;
}
return result;

🤖 Agent-generated

Comment on lines +996 to +999
callAgentFunction: _outboundListener != null &&
operation.version.isAtLeast(A2uiProtocolVersion.v1_0)
? (call) => rpc.callAgentFunction(operation.surfaceId, call)
: null,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_createSurface forwards callAgentFunction straight to rpc.callAgentFunction without checking whether the surface still exists in model. An action suspended awaiting a local Future argument during DeleteSurfaceOp resumes after cancelSurfaceCalls and sends a new RPC. Resumed calls still send callAgentFunction messages for deleted surfaces and leave active timeout timers in RpcHandler.

Suggested change
callAgentFunction: _outboundListener != null &&
operation.version.isAtLeast(A2uiProtocolVersion.v1_0)
? (call) => rpc.callAgentFunction(operation.surfaceId, call)
: null,
callAgentFunction: _outboundListener != null &&
operation.version.isAtLeast(A2uiProtocolVersion.v1_0)
? (call) {
if (model.getSurface(operation.surfaceId) == null) {
return Future<Object?>.error(
A2uiRpcError(
"Surface '${operation.surfaceId}' was deleted.",
RpcErrorCode.cancelled,
),
);
}
return rpc.callAgentFunction(operation.surfaceId, call);
}
: null,

🤖 Agent-generated

Comment on lines +1108 to +1111
if (!reportTopLevelAgentError) rethrow;
if (_onError == null) rethrow;
_report(error, call.call);
return _failedArg;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_evaluateActionCall, _agentCall, and GenericBinder._runLocalFunction report RpcErrorCode.cancelled rejections as EXPRESSION_ERROR. Default deleteSurface hides this only because model.deleteSurface disposes the surface right before rpc.cancelSurfaceCalls runs. Custom ComponentContext.onError callbacks receive spurious EXPRESSION_ERROR reports during teardown, and calling cancelSurfaceCalls on a live surface emits client errors.

Suggested change
if (!reportTopLevelAgentError) rethrow;
if (_onError == null) rethrow;
_report(error, call.call);
return _failedArg;
if (!reportTopLevelAgentError) rethrow;
if (error is A2uiRpcError && error.rpcCode == RpcErrorCode.cancelled) {
return _failedArg;
}
if (_onError == null) rethrow;
_report(error, call.call);
return _failedArg;

🤖 Agent-generated

_agentCalls.markFailed(key);
_agentCalls.version.value++;
});
_agentCalls.entries.remove(key);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In onError, _agentCalls.entries.remove(key) runs after batch(...) finishes flushing subscribed effects. Any effect listener that calls resolveDynamicValue during that synchronous flush hits _agentCalls.lookup and gets the failed entry back. Synchronous retries triggered inside reactive listeners return null instead of re-dispatching the evicted agent call.

       onError: (Object error) {
         batch(() {
+          _agentCalls.entries.remove(key);
           entry.pending = false;
           entry.failed = true;
           entry.version.value++;
           _agentCalls.markFailed(key);
           _agentCalls.version.value++;
         });
-        _agentCalls.entries.remove(key);
         _report(error, call.call);
       },

🤖 Agent-generated

Comment on lines +32 to +34
- A function call naming a `catalogId` on a `DataContext` created without
`invokerForCatalog` is now reported as a `CATALOG_ERROR` rather than an
`EXPRESSION_ERROR`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ## Unreleased notes omit the newly exported public methods DataContext.resolveActionAsync and RpcHandler.cancelSurfaceCalls. They also leave out async event action resolution, surface disposal in MessageProcessor.dispose(), and call cancellation on deleteSurface. Package consumers reading the changelog miss new public APIs and surface teardown behavior introduced in this change.

Suggested change
- A function call naming a `catalogId` on a `DataContext` created without
`invokerForCatalog` is now reported as a `CATALOG_ERROR` rather than an
`EXPRESSION_ERROR`.
- A function call naming a `catalogId` on a `DataContext` created without
`invokerForCatalog` is now reported as a `CATALOG_ERROR` rather than an
`EXPRESSION_ERROR`.
- Added `RpcHandler.cancelSurfaceCalls` and `DataContext.resolveActionAsync`,
resolved `action.event` fields asynchronously when arguments call the agent,
cancelled pending surface RPCs on `deleteSurface`, and disposed surfaces in
`MessageProcessor.dispose()`.

🤖 Agent-generated

expect(agentCalls, isEmpty);
await _flush();
expect(clientErrors.single.code, 'CATALOG_ERROR');
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No unit test exercises a non-system function call with catalogId on a DataContext created without invokerForCatalog. Existing tests in catalog_resolution_test.dart only cover @index, which exits before _invokeCall. Re-adding functionName would emit EXPRESSION_ERROR and forward unresolvable catalog calls to callAgentFunction without failing tests.

Suggested change
});
});
test(
'a catalogId on a DataContext without invokerForCatalog is a '
'CATALOG_ERROR', () {
final errors = <A2uiExpressionError>[];
final context = DataContext(
DataModel(),
_catalog().invoke,
'/',
onError: errors.add,
protocolVersion: A2uiProtocolVersion.v1_0,
callAgentFunction: callAgent,
);
expect(
context.resolveDynamicValue({
'@call': 'remote',
'catalogId': 'elsewhere',
'args': <String, Object?>{},
}),
isNull,
);
expect(agentCalls, isEmpty);
expect(errors.single.code, 'CATALOG_ERROR');
});

🤖 Agent-generated

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants