Skip to content

Commit 61dc948

Browse files
committed
docs(js/testing): demonstrate queue, error injection, toolResponses, and interrupts in the sample
Extend testing-sample so the sample app exercises the features added during review, not just the base mockModel/echoModel: - a confirmBooking interrupt tool shows a human-in-the-loop pause/resume round-trip (via restart). The app now imports genkit/beta since interrupts are a beta feature; everything else is unchanged. - response-queue scripting (respond: [...]) for a concise two-turn tool interaction with no branching callback. - error injection via a queued Error. - model.toolResponses to assert which tools ran and what they returned, replacing manual message digging. Covered under both node:test (10) and vitest (6); README updated.
1 parent b5075cb commit 61dc948

4 files changed

Lines changed: 182 additions & 7 deletions

File tree

js/testapps/testing-sample/README.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ streaming deterministically, without a live model, network, or API key.
1010
- a `recommendDish` flow that asks for a **structured** recommendation, then
1111
applies its own logic — validating the result and deriving `withinBudget`;
1212
- a `streamRecommendation` flow that streams the model's tokens out through the
13-
flow's own stream.
13+
flow's own stream;
14+
- a `confirmBooking` tool that `interrupt()`s for human confirmation
15+
(human-in-the-loop). This is why the app imports `genkit/beta` — interrupts
16+
are a beta feature; everything else works identically on the stable `genkit`.
1417

1518
`createMenuApp()` is a factory so each test builds a fresh, isolated app. The
1619
default model is referenced by name only (`'menuModel'`), so tests register a
@@ -24,7 +27,16 @@ mock under that name and the app code runs unchanged.
2427
*your* code, not the model.
2528
- **Error paths** — script an invalid response and assert the flow rejects it.
2629
- **Tool round-trip** — return `toolRequests` from the mock; the framework
27-
dispatches the real tool and calls the model again with the result.
30+
dispatches the real tool and calls the model again with the result. Assert
31+
what ran via `model.toolResponses` (the tool results fed back to the model).
32+
- **Scripting with a queue** — pass `respond: [...]` an array consumed one item
33+
per call (last repeating), so a multi-turn tool interaction needs no branching
34+
callback.
35+
- **Injected failures** — a queued `Error` (`respond: [new Error(...)]`) is
36+
thrown when reached, to test how a flow handles a model failure.
37+
- **Human-in-the-loop** — the model requests `confirmBooking`, the tool
38+
interrupts, generation pauses (`response.interrupts`), then `restart` resumes
39+
it with the human's decision so the tool books the dish.
2840
- **Streaming through a flow** — drive `flow.stream(...)`, emit chunks via
2941
`sendChunk`, and assert chunks arrive through the flow plus the final output.
3042
- **`echoModel`** — a zero-config model that echoes the whole assembled request
@@ -34,8 +46,8 @@ mock under that name and the app code runs unchanged.
3446
- **Inspection**`model.lastRequest` / `lastRequestMessage` (a genkit
3547
`Message`, so `.text` / `.media` work like on a response) / `lastRequestText`
3648
(the full assembled conversation as a string — works on *any* mock, including
37-
structured-output paths where `echoModel` can't be used) / `requests` /
38-
`requestCount`.
49+
structured-output paths where `echoModel` can't be used) / `toolResponses` /
50+
`requests` / `requestCount`.
3951

4052
`genkit/testing` is runner-agnostic. The same patterns are shown twice:
4153
`tests/menu_test.ts` under Node's built-in `node:test`, and

js/testapps/testing-sample/src/menu.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { genkit, z } from 'genkit';
17+
// `genkit/beta` is a superset of `genkit` — used here because the
18+
// human-in-the-loop `confirmBooking` tool relies on interrupts, a beta feature.
19+
// Everything else in this app works identically on the stable `genkit` entry.
20+
import { genkit } from 'genkit/beta';
21+
import { z } from 'genkit';
1822

1923
/** What we ask the model to produce: a structured recommendation. */
2024
export const RecommendationSchema = z.object({
@@ -134,9 +138,29 @@ export function createMenuApp() {
134138
}
135139
);
136140

141+
/**
142+
* A human-in-the-loop tool: booking a dish needs explicit confirmation, so the
143+
* tool `interrupt()`s on first run to hand control back to the caller, and
144+
* completes once generation is resumed with the human's answer. Tests drive
145+
* the pause -> resume round-trip with `mockModel` (see menu_test.ts).
146+
*/
147+
const confirmBooking = ai.defineTool(
148+
{
149+
name: 'confirmBooking',
150+
description: 'Confirm with the user before booking a dish.',
151+
inputSchema: z.object({ dish: z.string() }),
152+
outputSchema: z.string(),
153+
},
154+
async ({ dish }, { interrupt, resumed }) => {
155+
if (resumed) return `Booked: ${dish}.`;
156+
return interrupt({ dish });
157+
}
158+
);
159+
137160
return {
138161
ai,
139162
dailySpecial,
163+
confirmBooking,
140164
recommendPrompt,
141165
recommendDish,
142166
streamRecommendation,
@@ -150,6 +174,7 @@ export function createMenuApp() {
150174
export const {
151175
ai,
152176
dailySpecial,
177+
confirmBooking,
153178
recommendPrompt,
154179
recommendDish,
155180
streamRecommendation,

js/testapps/testing-sample/tests/menu.vitest.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,12 @@ import { createMenuApp } from '../src/menu.js';
2424
// unchanged.
2525
type App = ReturnType<typeof createMenuApp>;
2626
let ai: App['ai'];
27+
let confirmBooking: App['confirmBooking'];
2728
let recommendDish: App['recommendDish'];
2829
let recommendPrompt: App['recommendPrompt'];
2930
let streamRecommendation: App['streamRecommendation'];
3031
beforeEach(() => {
31-
({ ai, recommendDish, recommendPrompt, streamRecommendation } =
32+
({ ai, confirmBooking, recommendDish, recommendPrompt, streamRecommendation } =
3233
createMenuApp());
3334
});
3435

@@ -105,6 +106,50 @@ describe('recommendDish flow — tool round-trip (vitest)', () => {
105106

106107
expect(out.dish).toBe('Mushroom risotto');
107108
expect(model.requestCount).toBe(2);
109+
expect(model.toolResponses[0]?.name).toBe('dailySpecial');
110+
});
111+
});
112+
113+
describe('human-in-the-loop with interrupts (vitest)', () => {
114+
it('pauses on confirmBooking, then resumes to complete the booking', async () => {
115+
const model = mockModel(ai, {
116+
name: 'menuModel',
117+
info: { supports: { tools: true } },
118+
// Queued responses: request the interrupting tool, then answer.
119+
respond: [
120+
{
121+
toolRequests: [
122+
{ name: 'confirmBooking', input: { dish: 'Mushroom risotto' } },
123+
],
124+
},
125+
{ text: 'Enjoy your meal!' },
126+
],
127+
});
128+
129+
const paused = await ai.generate({
130+
model,
131+
prompt: 'Book the risotto.',
132+
tools: [confirmBooking],
133+
});
134+
expect(paused.interrupts.length).toBe(1);
135+
expect(paused.interrupts[0].toolRequest.name).toBe('confirmBooking');
136+
137+
const done = await ai.generate({
138+
model,
139+
messages: paused.messages,
140+
tools: [confirmBooking],
141+
resume: {
142+
restart: confirmBooking.restart(paused.interrupts[0], {
143+
confirmed: true,
144+
}),
145+
},
146+
});
147+
148+
expect(done.text).toBe('Enjoy your meal!');
149+
expect(model.requestCount).toBe(2);
150+
expect(String(model.toolResponses[0]?.output)).toMatch(
151+
/Booked: Mushroom risotto/
152+
);
108153
});
109154
});
110155

js/testapps/testing-sample/tests/menu_test.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,12 @@ import { createMenuApp } from '../src/menu.js';
2626
// shared registry.
2727
type App = ReturnType<typeof createMenuApp>;
2828
let ai: App['ai'];
29+
let confirmBooking: App['confirmBooking'];
2930
let recommendDish: App['recommendDish'];
3031
let recommendPrompt: App['recommendPrompt'];
3132
let streamRecommendation: App['streamRecommendation'];
3233
beforeEach(() => {
33-
({ ai, recommendDish, recommendPrompt, streamRecommendation } =
34+
({ ai, confirmBooking, recommendDish, recommendPrompt, streamRecommendation } =
3435
createMenuApp());
3536
});
3637

@@ -209,3 +210,95 @@ describe('streamRecommendation flow — streaming through the flow', () => {
209210
assert.equal(await output, 'Try the risotto.');
210211
});
211212
});
213+
214+
describe('scripting responses with a queue', () => {
215+
it('scripts a two-turn tool interaction with an array of responses', async () => {
216+
const model = mockModel(ai, {
217+
name: 'menuModel',
218+
info: { supports: { tools: true } },
219+
// Turn 1: ask for the special. Turn 2: return the recommendation. The
220+
// array is consumed one item per call, so no branching callback is needed.
221+
respond: [
222+
{
223+
toolRequests: [
224+
{ name: 'dailySpecial', input: { restaurant: 'Lumen' } },
225+
],
226+
},
227+
{
228+
text: JSON.stringify({
229+
dish: 'Mushroom risotto',
230+
reason: "It's the daily special.",
231+
priceUSD: 22,
232+
}),
233+
},
234+
],
235+
});
236+
237+
const out = await recommendDish({
238+
restaurant: 'Lumen',
239+
mood: 'curious',
240+
budgetUSD: 40,
241+
});
242+
243+
assert.equal(out.dish, 'Mushroom risotto');
244+
assert.equal(model.requestCount, 2);
245+
assert.equal(model.toolResponses[0]?.name, 'dailySpecial');
246+
});
247+
});
248+
249+
describe('model failure handling', () => {
250+
it('surfaces a model error injected via a queued Error', async () => {
251+
// A queued Error is thrown when reached — here on the very first call — so
252+
// you can test how a flow behaves when the model fails.
253+
mockModel(ai, {
254+
name: 'menuModel',
255+
respond: [new Error('model overloaded')],
256+
});
257+
258+
await assert.rejects(
259+
recommendDish({ restaurant: 'Lumen', mood: 'cozy', budgetUSD: 30 }),
260+
/model overloaded/
261+
);
262+
});
263+
});
264+
265+
describe('human-in-the-loop with interrupts', () => {
266+
it('pauses on confirmBooking, then resumes to complete the booking', async () => {
267+
const model = mockModel(ai, {
268+
name: 'menuModel',
269+
info: { supports: { tools: true } },
270+
respond: [
271+
{
272+
toolRequests: [
273+
{ name: 'confirmBooking', input: { dish: 'Mushroom risotto' } },
274+
],
275+
},
276+
{ text: 'Enjoy your meal!' },
277+
],
278+
});
279+
280+
// First pass: the tool interrupts, so generation pauses awaiting the human.
281+
const paused = await ai.generate({
282+
model,
283+
prompt: 'Book the risotto.',
284+
tools: [confirmBooking],
285+
});
286+
assert.equal(paused.interrupts.length, 1);
287+
assert.equal(paused.interrupts[0].toolRequest.name, 'confirmBooking');
288+
289+
// The human confirms; `restart` re-runs the tool with the decision (so its
290+
// `resumed` branch books the dish), then the model finishes.
291+
const done = await ai.generate({
292+
model,
293+
messages: paused.messages,
294+
tools: [confirmBooking],
295+
resume: {
296+
restart: confirmBooking.restart(paused.interrupts[0], { confirmed: true }),
297+
},
298+
});
299+
300+
assert.equal(done.text, 'Enjoy your meal!');
301+
assert.equal(model.requestCount, 2);
302+
assert.match(String(model.toolResponses[0]?.output), /Booked: Mushroom risotto/);
303+
});
304+
});

0 commit comments

Comments
 (0)