Skip to content

Commit 8dd6d32

Browse files
jirispilkaclaudeCopilotMQ37
authored
feat: Split get-actor-run into data + -widget tools (#734)
* feat: Split get-actor-run into data + -widget tools Final step (6 of 6) of the #577 umbrella rollout. Mirrors the decoupled-pattern recipe from #722 (fetch-actor-details), #723 (search-actors), and #724 (call-actor): - get-actor-run is now mode-independent and data-only. No tool-level widget _meta in either mode; runs category entry is a plain ToolEntry instead of a mode map. - New get-actor-run-widget (apps-only) renders the live progress widget. Input is strict: { runId } only. Tool- and response-level widget _meta (ui.resourceUri = ui://widget/actor-run.html). Reuses the shared buildGetActorRunSuccessResponse({ widget: true }) helper. - buildGetActorRunSuccessResponse widget branch now also sets openai/widgetDescription on the response _meta, matching the other three widget tools. - Apps server instructions: added the fourth disambiguation bullet pairing get-actor-run (silent data lookup) with get-actor-run-widget (live progress widget), using the same vocabulary as the existing three splits. WORKFLOW_RULES untouched — the "NEVER poll get-actor-run after call-actor-widget" rule is orthogonal. - Deleted src/tools/apps/get_actor_run.ts; widget rendering now lives in the sibling tool rather than a mode toggle. https://claude.ai/code/session_01SF9P6g91UrVMahn4bLsUNf * chore: Address staff review findings post-split - Drop stale rolling-rollout note from server-instructions header (split is now complete) - Extend "no polling after widget" rule to also cover get-actor-run-widget - Rename integration test to drop misleading "widget" label (it exercises the data tool) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: Remove paymentRequired flag from get-actor-run tools * Update src/tools/core/get_actor_run_common.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/tools/apps/get_actor_run_widget.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * test: Add integration test for x402 _meta advertising on paid tools (#768) Asserts that tools with paymentRequired: true advertise _meta.x402 with the expected fields (scheme, network, asset, payTo, amount) when the server runs in x402 payment mode, and that free tools do not. Pins the expected paid set with a hardcoded list so silent drift (e.g. a tool losing paymentRequired) is caught here. Tracks #766. * feat: Add paymentRequired flag to get-actor-run tools and update documentation * refactor: Skip tests for auto mode client capabilities and rename itemCount to totalItemCount --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jakub Kopecký <themq37@gmail.com>
1 parent 37ebb00 commit 8dd6d32

10 files changed

Lines changed: 276 additions & 63 deletions

File tree

src/const.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export enum HelperTools {
3131
ACTOR_OUTPUT_GET = 'get-actor-output',
3232
ACTOR_RUNS_ABORT = 'abort-actor-run',
3333
ACTOR_RUNS_GET = 'get-actor-run',
34+
ACTOR_RUNS_GET_WIDGET = 'get-actor-run-widget',
3435
ACTOR_RUNS_LOG = 'get-actor-log',
3536
ACTOR_RUN_LIST_GET = 'get-actor-run-list',
3637
DATASET_GET = 'get-dataset',

src/tools/apps/get_actor_run.ts

Lines changed: 0 additions & 38 deletions
This file was deleted.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import dedent from 'dedent';
2+
import { z } from 'zod';
3+
4+
import { HelperTools } from '../../const.js';
5+
import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js';
6+
import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js';
7+
import { compileSchema } from '../../utils/ajv.js';
8+
import { logHttpError } from '../../utils/logging.js';
9+
import {
10+
buildGetActorRunError,
11+
buildGetActorRunSuccessResponse,
12+
fetchActorRunData,
13+
} from '../core/get_actor_run_common.js';
14+
import { getActorRunOutputSchema } from '../structured_output_schemas.js';
15+
16+
/**
17+
* Widget-only input: `runId` only. In the normal tool path, AJV validation
18+
* runs first and strips unknown keys at the boundary; `.strict()` mainly
19+
* protects any bypass paths by rejecting stray keys before use here.
20+
*/
21+
const getActorRunWidgetArgsSchema = z.object({
22+
runId: z.string()
23+
.min(1)
24+
.describe('The ID of the Actor run.'),
25+
}).strict();
26+
27+
const GET_ACTOR_RUN_WIDGET_DESCRIPTION = dedent`
28+
Render an interactive UI element (widget) showing live progress and status of an Actor run.
29+
30+
Use this tool ONLY when the user explicitly wants to see run progress visually
31+
(e.g., "show progress for run y2h7sK3Wc", "display the status of that run").
32+
33+
For silent data lookups (run status, dataset IDs, stats, resource IDs), use
34+
${HelperTools.ACTOR_RUNS_GET} instead — it returns the same data without rendering a widget.
35+
36+
Input: the run ID only.
37+
`;
38+
39+
export const getActorRunWidgetTool: ToolEntry = Object.freeze({
40+
type: 'internal',
41+
name: HelperTools.ACTOR_RUNS_GET_WIDGET,
42+
description: GET_ACTOR_RUN_WIDGET_DESCRIPTION,
43+
inputSchema: z.toJSONSchema(getActorRunWidgetArgsSchema) as ToolInputSchema,
44+
outputSchema: getActorRunOutputSchema,
45+
ajvValidate: compileSchema(z.toJSONSchema(getActorRunWidgetArgsSchema)),
46+
paymentRequired: true,
47+
_meta: {
48+
...getWidgetConfig(WIDGET_URIS.ACTOR_RUN)?.meta,
49+
},
50+
annotations: {
51+
title: 'Get Actor run (widget)',
52+
readOnlyHint: true,
53+
destructiveHint: false,
54+
idempotentHint: true,
55+
openWorldHint: false,
56+
},
57+
call: async (toolArgs: InternalToolArgs) => {
58+
const { args, apifyClient: client, mcpSessionId } = toolArgs;
59+
const parsed = getActorRunWidgetArgsSchema.parse(args);
60+
61+
try {
62+
const fetchResult = await fetchActorRunData({
63+
runId: parsed.runId,
64+
client,
65+
mcpSessionId,
66+
});
67+
68+
if ('error' in fetchResult) {
69+
return fetchResult.error;
70+
}
71+
72+
return buildGetActorRunSuccessResponse({ ...fetchResult.result, widget: true });
73+
} catch (error) {
74+
logHttpError(error, 'Failed to get Actor run (widget)', { runId: parsed.runId });
75+
return buildGetActorRunError(parsed.runId, error);
76+
}
77+
},
78+
} as const);

src/tools/categories.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { ServerMode } from '../types.js';
1818
import { appsCallActor } from './apps/call_actor.js';
1919
import { appsCallActorWidget } from './apps/call_actor_widget.js';
2020
import { fetchActorDetailsWidgetTool } from './apps/fetch_actor_details_widget.js';
21-
import { appsGetActorRun } from './apps/get_actor_run.js';
21+
import { getActorRunWidgetTool } from './apps/get_actor_run_widget.js';
2222
import { searchActorsWidgetTool } from './apps/search_actors_widget.js';
2323
import { abortActorRun } from './common/abort_actor_run.js';
2424
import { addTool } from './common/add_actor.js';
@@ -76,13 +76,14 @@ export const toolCategories = {
7676
{ apps: searchActorsWidgetTool },
7777
{ apps: fetchActorDetailsWidgetTool },
7878
{ apps: appsCallActorWidget },
79+
{ apps: getActorRunWidgetTool },
7980
],
8081
docs: [
8182
searchApifyDocsTool,
8283
fetchApifyDocsTool,
8384
],
8485
runs: [
85-
{ default: defaultGetActorRun, apps: appsGetActorRun },
86+
defaultGetActorRun,
8687
getUserRunsList,
8788
getActorRunLog,
8889
abortActorRun,

src/tools/core/get_actor_run_common.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { generateSchemaFromItems } from '../../utils/schema_generation.js';
1313
import { getActorRunOutputSchema } from '../structured_output_schemas.js';
1414

1515
/**
16-
* Zod schema for get-actor-run arguments — shared between default and apps variants.
16+
* Zod schema for get-actor-run arguments — shared between default and widget variants.
1717
*/
1818
export const getActorRunArgs = z.object({
1919
runId: z.string()
@@ -27,15 +27,17 @@ The results will include run metadata (status, timestamps), performance stats, a
2727
USAGE:
2828
- Use when the user asks about a specific run's status or details.
2929
- Use to check the status of a run started with call-actor (e.g., before fetching output).
30-
- If you used call-actor-widget and a widget was rendered, do not poll get-actor-run; the widget handles status.
30+
- If a visual progress widget is available in this session, prefer that tool for UI rendering.
31+
- Returns pure data with no UI.
3132
3233
USAGE EXAMPLES:
3334
- user_input: Show details of run y2h7sK3Wc (where y2h7sK3Wc is an existing run)
3435
- user_input: What is the datasetId for run y2h7sK3Wc?`;
3536

3637
/**
3738
* Shared tool metadata for get-actor-run — everything except the `call` handler.
38-
* Used by both default and apps variants.
39+
* Mode-independent, data-only. No widget _meta here; the widget variant in
40+
* `src/tools/apps/get_actor_run_widget.ts` owns UI rendering.
3941
*/
4042
export const getActorRunMetadata: Omit<HelperTool, 'call'> = {
4143
type: 'internal',
@@ -45,10 +47,6 @@ export const getActorRunMetadata: Omit<HelperTool, 'call'> = {
4547
outputSchema: getActorRunOutputSchema,
4648
ajvValidate: compileSchema(z.toJSONSchema(getActorRunArgs)),
4749
paymentRequired: true,
48-
// openai/* and ui keys are stripped in non-apps mode by stripWidgetMeta() in src/utils/tools.ts
49-
_meta: {
50-
...getWidgetConfig(WIDGET_URIS.ACTOR_RUN)?.meta,
51-
},
5250
annotations: {
5351
title: 'Get Actor run',
5452
readOnlyHint: true,
@@ -125,6 +123,7 @@ export function buildGetActorRunSuccessResponse(
125123
_meta: {
126124
...(getWidgetConfig(WIDGET_URIS.ACTOR_RUN)?.meta ?? {}),
127125
...(buildUsageMeta(run) ?? {}),
126+
'openai/widgetDescription': `Actor run progress for ${structuredContent.actorName ?? structuredContent.runId}`,
128127
},
129128
});
130129
}

src/utils/server-instructions/index.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,6 @@
55
* only when the resolved server mode is `'apps'`. Default-mode clients never
66
* see widget tool names like `search-actors-widget` or `fetch-actor-details-widget`,
77
* avoiding hallucinated calls to tools absent from `tools/list`.
8-
*
9-
* Note: the `-widget` suffix split is rolling out per-tool. `fetch-actor-details`,
10-
* `search-actors`, and `call-actor` are already split; `get-actor-run` still renders
11-
* a widget on its base name until its own split lands.
128
*/
139

1410
import { HelperTools, RAG_WEB_BROWSER } from '../../const.js';
@@ -52,7 +48,7 @@ ${isApps ? `
5248
## Widget workflow (applies when tool responses include widget metadata)
5349
Some clients render widget-backed Actor tools: the response includes a live UI that automatically polls run status. When a widget is rendered, follow-up status polling by the model is a forbidden duplicate.
5450
55-
- **Never call \`${HelperTools.ACTOR_RUNS_GET}\` after \`${HelperTools.ACTOR_CALL_WIDGET}\`.** The widget renders live progress and polls itself — stop after the widget response and defer to it for run status.
51+
- **Never call \`${HelperTools.ACTOR_RUNS_GET}\` or \`${HelperTools.ACTOR_RUNS_GET_WIDGET}\` after \`${HelperTools.ACTOR_CALL_WIDGET}\` or \`${HelperTools.ACTOR_RUNS_GET_WIDGET}\`.** Both widgets render live progress and poll themselves — stop after the widget response and defer to it for run status. Re-rendering the same run via \`${HelperTools.ACTOR_RUNS_GET_WIDGET}\` is a duplicate.
5652
- Polling \`${HelperTools.ACTOR_RUNS_GET}\` after \`${HelperTools.ACTOR_CALL}\` (the silent async variant, no widget) is fine — that tool renders no UI, so polling is expected when you need the run status.
5753
` : ''}
5854
## Tool dependencies and disambiguation
@@ -76,6 +72,7 @@ ${isApps ? `- **Data vs widget Actor tools (when the client supports widgets):**
7672
- \`${HelperTools.STORE_SEARCH}\` is a silent data lookup (Actor list for name resolution) with no UI; \`${HelperTools.STORE_SEARCH_WIDGET}\` renders an interactive UI element (widget) with Actor search results for the user to browse — use it only when the user explicitly asks to search or discover Actors.
7773
- \`${HelperTools.ACTOR_GET_DETAILS}\` is a silent data lookup (input schema, README, metadata) with no UI; \`${HelperTools.ACTOR_GET_DETAILS_WIDGET}\` renders an interactive UI element (widget) with Actor details — use it only when the user explicitly asks to see or browse the Actor.
7874
- \`${HelperTools.ACTOR_CALL}\` is a silent async start (returns runId, no UI); \`${HelperTools.ACTOR_CALL_WIDGET}\` renders an interactive UI element (widget) that tracks live Actor run progress — use it only when the user explicitly asks to see progress.
75+
- \`${HelperTools.ACTOR_RUNS_GET}\` is a silent data lookup (run status, dataset IDs, stats) with no UI; \`${HelperTools.ACTOR_RUNS_GET_WIDGET}\` renders an interactive UI element (widget) showing live run progress for the user — use it only when the user explicitly asks to see run progress.
7976
- When the next step is running an Actor, prefer silent lookups (\`${HelperTools.STORE_SEARCH}\`, \`${HelperTools.ACTOR_GET_DETAILS}\`) over widget-backed variants.
8077
` : ''}- **\`${HelperTools.STORE_SEARCH}\` vs ${RAG_WEB_BROWSER}:**
8178
\`${HelperTools.STORE_SEARCH}\` finds robust and reliable Actors for specific websites; ${RAG_WEB_BROWSER} is a general and versatile web scraping tool.

tests/integration/suite.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2536,7 +2536,10 @@ export function createIntegrationTestsSuite(
25362536
await client.close();
25372537
});
25382538

2539-
it('auto mode: client advertising UI capability receives apps-mode tools with widget metadata', async () => {
2539+
// TODO: re-enable when auto-detect is re-enabled in resolveServerMode (src/types.ts).
2540+
// Currently 'auto' resolves to DEFAULT regardless of client UI capability, so these
2541+
// tests cannot exercise capability-driven mode resolution.
2542+
it.skip('auto mode: client advertising UI capability receives apps-mode tools with widget metadata', async () => {
25402543
// serverMode omitted → server defaults to 'auto'; client sends UI capability → server resolves to 'apps'
25412544
client = await createClientFn({
25422545
clientCapabilities: {
@@ -2550,7 +2553,7 @@ export function createIntegrationTestsSuite(
25502553
await client.close();
25512554
});
25522555

2553-
it('auto mode: client without UI capability receives default-mode tools without widget metadata', async () => {
2556+
it.skip('auto mode: client without UI capability receives default-mode tools without widget metadata', async () => {
25542557
// serverMode omitted → server defaults to 'auto'; client sends no UI capability → server resolves to 'default'
25552558
client = await createClientFn();
25562559
const tools = await client.listTools();
@@ -2612,6 +2615,61 @@ export function createIntegrationTestsSuite(
26122615
},
26132616
);
26142617

2618+
// x402 payment mode only works with Streamable-HTTP transport (requires HTTP headers).
2619+
it.runIf(options.transport === 'streamable-http')(
2620+
'should advertise x402 metadata on all paymentRequired tools when x402 payment is enabled',
2621+
async () => {
2622+
// Hardcoded list of tools expected to advertise _meta.x402 (i.e. paymentRequired: true).
2623+
// Kept independent of any production constant so this test pins the expected paid set
2624+
// and any silent drift (e.g. a tool losing paymentRequired) is caught here.
2625+
const paidToolNames = [
2626+
HelperTools.ACTOR_CALL,
2627+
HelperTools.ACTOR_OUTPUT_GET,
2628+
HelperTools.ACTOR_RUNS_GET,
2629+
HelperTools.ACTOR_RUNS_LOG,
2630+
HelperTools.ACTOR_RUNS_ABORT,
2631+
HelperTools.DATASET_GET,
2632+
HelperTools.DATASET_GET_ITEMS,
2633+
HelperTools.DATASET_SCHEMA_GET,
2634+
HelperTools.KEY_VALUE_STORE_GET,
2635+
HelperTools.KEY_VALUE_STORE_KEYS_GET,
2636+
HelperTools.KEY_VALUE_STORE_RECORD_GET,
2637+
];
2638+
const freeToolNames = [HelperTools.STORE_SEARCH, HelperTools.DOCS_SEARCH];
2639+
2640+
client = await createClientFn({
2641+
payment: 'x402',
2642+
tools: [...paidToolNames, ...freeToolNames],
2643+
});
2644+
2645+
const toolsList = await client.listTools();
2646+
2647+
// Positive: paid tools advertise _meta.x402 with the expected fields.
2648+
for (const toolName of paidToolNames) {
2649+
const tool = toolsList.tools.find((t) => t.name === toolName);
2650+
expect(tool, `Tool "${toolName}" should exist in the tools list`).toBeDefined();
2651+
2652+
const x402 = tool?._meta?.x402 as Record<string, unknown> | undefined;
2653+
expect(x402, `Tool "${toolName}" should advertise _meta.x402`).toBeDefined();
2654+
expect(x402?.paymentRequired, `Tool "${toolName}" x402.paymentRequired should be true`).toBe(true);
2655+
2656+
for (const field of ['scheme', 'network', 'asset', 'payTo', 'amount'] as const) {
2657+
expect(x402?.[field], `Tool "${toolName}" should advertise x402.${field}`).toBeDefined();
2658+
}
2659+
}
2660+
2661+
// Negative: free tools must not advertise _meta.x402.
2662+
for (const toolName of freeToolNames) {
2663+
const tool = toolsList.tools.find((t) => t.name === toolName);
2664+
expect(tool, `Tool "${toolName}" should exist in the tools list`).toBeDefined();
2665+
const meta = tool?._meta as Record<string, unknown> | undefined;
2666+
expect(meta?.x402, `Tool "${toolName}" should not advertise _meta.x402`).toBeUndefined();
2667+
}
2668+
2669+
await client.close();
2670+
},
2671+
);
2672+
26152673
// x402 payment mode only works with Streamable-HTTP transport (requires HTTP headers).
26162674
it.runIf(options.transport === 'streamable-http')(
26172675
'should return x402 payment error when calling paymentRequired tool without payment signature',
@@ -2634,7 +2692,7 @@ export function createIntegrationTestsSuite(
26342692
},
26352693
);
26362694

2637-
it('should return required structuredContent fields for ActorRun widget (get-actor-run)', async () => {
2695+
it('should return required structuredContent fields for get-actor-run', async () => {
26382696
client = await createClientFn({ tools: ['actors', 'runs'] });
26392697

26402698
// First, start an async actor run to get a runId
@@ -2663,7 +2721,7 @@ export function createIntegrationTestsSuite(
26632721
startedAt: string;
26642722
dataset?: {
26652723
datasetId: string;
2666-
itemCount: number;
2724+
totalItemCount: number;
26672725
};
26682726
} };
26692727

@@ -2678,7 +2736,7 @@ export function createIntegrationTestsSuite(
26782736
if (runContent.structuredContent?.status === 'SUCCEEDED') {
26792737
expect(runContent.structuredContent?.dataset).toBeDefined();
26802738
expect(runContent.structuredContent?.dataset?.datasetId).toBeDefined();
2681-
expect(runContent.structuredContent?.dataset?.itemCount).toBeDefined();
2739+
expect(runContent.structuredContent?.dataset?.totalItemCount).toBeDefined();
26822740
}
26832741
});
26842742

tests/unit/tools.categories.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describe('getCategoryTools', () => {
6666
expect(defaultCallActor).not.toBe(appsCallActor);
6767
});
6868

69-
it('should return different get-actor-run variants based on mode', () => {
69+
it('should share the same get-actor-run tool across modes (mode-independent)', () => {
7070
const defaultResult = getCategoryTools('default');
7171
const appsResult = getCategoryTools('apps');
7272

@@ -75,8 +75,8 @@ describe('getCategoryTools', () => {
7575

7676
expect(defaultGetRun).toBeDefined();
7777
expect(appsGetRun).toBeDefined();
78-
// Different objects (different implementations)
79-
expect(defaultGetRun).not.toBe(appsGetRun);
78+
// Same object — data-only, mode-independent. UI rendering lives in get-actor-run-widget.
79+
expect(defaultGetRun).toBe(appsGetRun);
8080
});
8181

8282
it('should share identical tools for mode-independent categories', () => {

0 commit comments

Comments
 (0)