A Button Names a Tool and Stops There

5 min
GoalScreen course, part 4. There is no code in onTap, only a tool name. Whether that name actually reached the host cannot be seen in a picture, so the harness presses the buttons and writes down what arrived.

Part 3 brought values onto the screen. The other direction is left.

There is no code in onTap

{ "type": "button", "label": "Call next", "onTap": { "type": "tool", "tool": "queue.next" } },
{ "type": "button", "label": "Take a ticket", "onTap": { "type": "tool", "tool": "queue.take" } }

onTap is { type, tool }. "Pressing this calls a tool named queue.next" is the whole of it; what that tool does is not written down.

It could not be. This file came from the server built in the server course, and that is where the work happens.

The receiving side

rt.buildUI(
  context: c,
  // The name arrives here. The host does not know what it means.
  onToolCall: (tool, params) async => fired.add(tool),
)

This is the slot left as an empty function in part 1. In a real app it holds one line from client part 3.

onToolCall: (tool, params) async {
  // The name came off the screen. It is passed straight through.
  final r = await client.callTool(tool, params);
  final state = jsonDecode((r.content.first as TextContent).text)
      as Map<String, dynamic>;
  state.forEach(rt.stateManager.set);
},

tool is passed straight through. There is no if (tool == 'queue.next') — put one in and the host needs editing every time the server grows a tool.

The picture cannot settle this

Here is where captures first run out. step4.png shows two buttons. What it does not show is what those buttons send when pressed.

Delete onTap entirely and the picture is identical.

So the harness presses them.

fired.clear();
await tester.tap(find.text('Call next'));
await tester.pump(const Duration(milliseconds: 40));
await tester.tap(find.text('Take a ticket'));
await tester.pump(const Duration(milliseconds: 40));
say('pressed -> the host was asked for: ${fired.join(", ")}');
pressed -> the host was asked for: queue.next, queue.take

fired is the list of names onToolCall received, in order. That line lands in run.log, and the check reads it back.

# Fails if the host knows the tool names
grep -rq 'queue\.next\|queue\.take' $SRC && \
  die "step4: the host knows the tool names"
# Fails if pressing did not send the names the screen asked for
grep -q 'the host was asked for: queue.next, queue.take' $LOG || \
  die "step4: pressing did not send the tool names the screen asked for"

The two lines only mean something together. The first alone says "the names are not in the host". The second alone passes even if they are. Overlapped, they say the name the screen gave is the name that went out.

Delete onTap and run: the second one fires.

   [5/8] step4 — the button names a tool and stops there
   step4: pressing did not send the tool names the screen asked for

Failure is not handled on the screen

What if queue.next is refused? The counter from server part 3 refuses to call anyone when nobody is waiting.

A refusal is a result too. The server returns isError and a reason, and that comes back as state along with everything else.

{ "type": "text", "text": "{{notice}}", "style": { "fontSize": 15, "color": "#9ca3af" } }

That one line near the bottom of step4.json is the spot. There is no separate error-handling syntax in a screen definition — a reason is just another value in state.

The render

step4  896 B, 12 lines, type "page" -> step4.png

43, then 2 waiting, two buttons, and called 43 in grey underneath.

Run it yourself

cd content/sample/course-ui
bash verify.sh
cat captures/run.log

What to take away

  1. onTap: { type: 'tool', tool: ... } — the screen knows the name and no more
  2. onToolCall passes it through — do not branch on the name
  3. A check that presses — when a capture cannot see it, write it to the log

Next

The last one. A single screen becoming two different shapes — and a list that does not know how long it is.

Run the sample

cd course-ui
bash verify.sh