Skip to content

Commit f837504

Browse files
committed
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
1 parent 57df5bc commit f837504

File tree

9 files changed

+211
-57
lines changed

9 files changed

+211
-57
lines changed

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: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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. `.strict()` rejects stray keys so callers
18+
* can't smuggle extra options into the widget variant.
19+
*/
20+
const getActorRunWidgetArgsSchema = z.object({
21+
runId: z.string()
22+
.min(1)
23+
.describe('The ID of the Actor run.'),
24+
}).strict();
25+
26+
const GET_ACTOR_RUN_WIDGET_DESCRIPTION = dedent`
27+
Render an interactive UI element (widget) showing live progress and status of an Actor run.
28+
29+
Use this tool ONLY when the user explicitly wants to see run progress visually
30+
(e.g., "show progress for run y2h7sK3Wc", "display the status of that run").
31+
32+
For silent data lookups (run status, dataset IDs, stats, resource IDs), use
33+
${HelperTools.ACTOR_RUNS_GET} instead — it returns the same data without rendering a widget.
34+
35+
Input: the run ID only.
36+
`;
37+
38+
export const getActorRunWidgetTool: ToolEntry = Object.freeze({
39+
type: 'internal',
40+
name: HelperTools.ACTOR_RUNS_GET_WIDGET,
41+
description: GET_ACTOR_RUN_WIDGET_DESCRIPTION,
42+
inputSchema: z.toJSONSchema(getActorRunWidgetArgsSchema) as ToolInputSchema,
43+
outputSchema: getActorRunOutputSchema,
44+
ajvValidate: compileSchema(z.toJSONSchema(getActorRunWidgetArgsSchema)),
45+
paymentRequired: true,
46+
_meta: {
47+
...getWidgetConfig(WIDGET_URIS.ACTOR_RUN)?.meta,
48+
},
49+
annotations: {
50+
title: 'Get Actor run (widget)',
51+
readOnlyHint: true,
52+
destructiveHint: false,
53+
idempotentHint: true,
54+
openWorldHint: false,
55+
},
56+
call: async (toolArgs: InternalToolArgs) => {
57+
const { args, apifyClient: client, mcpSessionId } = toolArgs;
58+
const parsed = getActorRunWidgetArgsSchema.parse(args);
59+
60+
try {
61+
const fetchResult = await fetchActorRunData({
62+
runId: parsed.runId,
63+
client,
64+
mcpSessionId,
65+
});
66+
67+
if ('error' in fetchResult) {
68+
return fetchResult.error;
69+
}
70+
71+
return buildGetActorRunSuccessResponse({ ...fetchResult.result, widget: true });
72+
} catch (error) {
73+
logHttpError(error, 'Failed to get Actor run (widget)', { runId: parsed.runId });
74+
return buildGetActorRunError(parsed.runId, error);
75+
}
76+
},
77+
} as const);

src/tools/categories.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import type { ServerMode, ToolEntry } from '../types.js';
1717
import { appsCallActor } from './apps/call_actor.js';
1818
import { appsCallActorWidget } from './apps/call_actor_widget.js';
1919
import { fetchActorDetailsWidgetTool } from './apps/fetch_actor_details_widget.js';
20-
import { appsGetActorRun } from './apps/get_actor_run.js';
20+
import { getActorRunWidgetTool } from './apps/get_actor_run_widget.js';
2121
import { searchActorsWidgetTool } from './apps/search_actors_widget.js';
2222
import { abortActorRun } from './common/abort_actor_run.js';
2323
import { addTool } from './common/add_actor.js';
@@ -75,13 +75,14 @@ export const toolCategories = {
7575
{ apps: searchActorsWidgetTool },
7676
{ apps: fetchActorDetailsWidgetTool },
7777
{ apps: appsCallActorWidget },
78+
{ apps: getActorRunWidgetTool },
7879
],
7980
docs: [
8081
searchApifyDocsTool,
8182
fetchApifyDocsTool,
8283
],
8384
runs: [
84-
{ default: defaultGetActorRun, apps: appsGetActorRun },
85+
defaultGetActorRun,
8586
getUserRunsList,
8687
getActorRunLog,
8788
abortActorRun,

src/tools/core/get_actor_run_common.ts

Lines changed: 6 additions & 11 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()
@@ -24,20 +24,18 @@ export const getActorRunArgs = z.object({
2424
const GET_ACTOR_RUN_DESCRIPTION = `Get detailed information about a specific Actor run by runId.
2525
The results will include run metadata (status, timestamps), performance stats, and resource IDs (datasetId, keyValueStoreId, requestQueueId).
2626
27-
CRITICAL WARNING: NEVER call this tool immediately after call-actor in UI mode. The call-actor response includes a widget that automatically polls for updates. Calling this tool after call-actor is FORBIDDEN and unnecessary.
28-
2927
USAGE:
30-
- Use ONLY when user explicitly asks about a specific run's status or details.
31-
- Use ONLY for runs that were started outside the current conversation.
32-
- DO NOT use this tool as part of the call-actor workflow in UI mode.
28+
- Use when the user asks about a specific run's status or details.
29+
- Returns pure data with no UI.
3330
3431
USAGE EXAMPLES:
3532
- user_input: Show details of run y2h7sK3Wc (where y2h7sK3Wc is an existing run)
3633
- user_input: What is the datasetId for run y2h7sK3Wc?`;
3734

3835
/**
3936
* Shared tool metadata for get-actor-run — everything except the `call` handler.
40-
* Used by both default and apps variants.
37+
* Mode-independent, data-only. No widget _meta here; the widget variant in
38+
* `src/tools/apps/get_actor_run_widget.ts` owns UI rendering.
4139
*/
4240
export const getActorRunMetadata: Omit<HelperTool, 'call'> = {
4341
type: 'internal',
@@ -47,10 +45,6 @@ export const getActorRunMetadata: Omit<HelperTool, 'call'> = {
4745
outputSchema: getActorRunOutputSchema,
4846
ajvValidate: compileSchema(z.toJSONSchema(getActorRunArgs)),
4947
paymentRequired: true,
50-
// openai/* and ui keys are stripped in non-apps mode by stripWidgetMeta() in src/utils/tools.ts
51-
_meta: {
52-
...getWidgetConfig(WIDGET_URIS.ACTOR_RUN)?.meta,
53-
},
5448
annotations: {
5549
title: 'Get Actor run',
5650
readOnlyHint: true,
@@ -127,6 +121,7 @@ export function buildGetActorRunSuccessResponse(
127121
_meta: {
128122
...(getWidgetConfig(WIDGET_URIS.ACTOR_RUN)?.meta ?? {}),
129123
...(buildUsageMeta(run) ?? {}),
124+
'openai/widgetDescription': `Actor run progress for ${structuredContent.actorName ?? structuredContent.runId}`,
130125
},
131126
});
132127
}

src/utils/server-instructions/apps.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const TOOL_DISAMBIGUATION = `
2424
- \`${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
2525
- \`${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 for the user to view — use it only when the user explicitly asks to see or browse the Actor
2626
- \`${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 for the user — use it only when the user explicitly asks to see progress
27+
- \`${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
2728
- When the next step is running an Actor, ALWAYS use \`${HelperTools.STORE_SEARCH}\` for name resolution, never \`${HelperTools.STORE_SEARCH_WIDGET}\``;
2829

2930
/** Returns server instructions for apps (MCP Apps UI) mode. */

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', () => {
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { WIDGET_URIS } from '../../src/resources/widgets.js';
4+
import { getActorRunWidgetTool } from '../../src/tools/apps/get_actor_run_widget.js';
5+
import type { HelperTool, InternalToolArgs } from '../../src/types.js';
6+
7+
/**
8+
* Apps / UI mode: get-actor-run-widget renders an interactive UI element (widget)
9+
* showing live Actor run progress. Carries widget `_meta` on both the tool definition
10+
* and the response. Input is strict: only `runId` is accepted.
11+
*/
12+
13+
const MOCK_RUN = {
14+
id: 'run-widget-1',
15+
actId: 'actor-id-rag',
16+
status: 'RUNNING',
17+
startedAt: new Date('2026-04-20T12:00:00.000Z'),
18+
stats: {},
19+
};
20+
21+
const MOCK_ACTOR = {
22+
username: 'apify',
23+
name: 'rag-web-browser',
24+
};
25+
26+
function stubApifyClient(): InternalToolArgs['apifyClient'] {
27+
return {
28+
run: (_id: string) => ({
29+
get: async () => MOCK_RUN,
30+
}),
31+
actor: (_id: string) => ({
32+
get: async () => MOCK_ACTOR,
33+
}),
34+
} as unknown as InternalToolArgs['apifyClient'];
35+
}
36+
37+
function stubArgs(args: Record<string, unknown>): InternalToolArgs {
38+
return {
39+
args,
40+
apifyToken: 'test-token',
41+
apifyClient: stubApifyClient(),
42+
extra: {} as InternalToolArgs['extra'],
43+
mcpServer: {} as InternalToolArgs['mcpServer'],
44+
apifyMcpServer: { options: { paymentProvider: undefined } } as InternalToolArgs['apifyMcpServer'],
45+
} as InternalToolArgs;
46+
}
47+
48+
describe('get-actor-run-widget response', () => {
49+
it('returns structured run status and widget _meta on the response', async () => {
50+
const result = await (getActorRunWidgetTool as HelperTool).call(
51+
stubArgs({ runId: 'run-widget-1' }),
52+
);
53+
54+
const { structuredContent, content, _meta } = result as {
55+
structuredContent: {
56+
runId: string;
57+
actorName?: string;
58+
status: string;
59+
startedAt: string;
60+
};
61+
content: { type: string; text: string }[];
62+
_meta?: { ui?: { resourceUri?: string; visibility?: readonly string[]; csp?: unknown }; 'openai/widgetDescription'?: string };
63+
};
64+
65+
expect(structuredContent.runId).toBe('run-widget-1');
66+
expect(structuredContent.actorName).toBe('apify/rag-web-browser');
67+
expect(structuredContent.status).toBe('RUNNING');
68+
expect(structuredContent.startedAt).toBe('2026-04-20T12:00:00.000Z');
69+
70+
// Short pointer text, not a JSON dump.
71+
expect(content).toHaveLength(1);
72+
expect(content[0].text).toContain('A progress widget has been rendered');
73+
expect(content[0].text).toContain('run-widget-1');
74+
75+
// Response-level widget _meta.
76+
expect(_meta?.ui?.resourceUri).toBe(WIDGET_URIS.ACTOR_RUN);
77+
expect(_meta?.ui?.visibility).toEqual(['model', 'app']);
78+
expect(_meta?.ui?.csp).toBeDefined();
79+
expect(_meta?.['openai/widgetDescription']).toContain('apify/rag-web-browser');
80+
});
81+
82+
it('carries widget _meta on the tool definition', () => {
83+
const tool = getActorRunWidgetTool as HelperTool;
84+
const meta = tool._meta as { ui?: { resourceUri?: string; visibility?: readonly string[]; csp?: unknown } };
85+
expect(meta.ui?.resourceUri).toBe(WIDGET_URIS.ACTOR_RUN);
86+
expect(meta.ui?.visibility).toEqual(['model', 'app']);
87+
expect(meta.ui?.csp).toBeDefined();
88+
});
89+
90+
it('declares a strict input schema accepting only runId', () => {
91+
const tool = getActorRunWidgetTool as HelperTool;
92+
93+
const schema = tool.inputSchema as { additionalProperties?: boolean; properties?: Record<string, unknown>; required?: string[] };
94+
expect(schema.additionalProperties).toBe(false);
95+
expect(Object.keys(schema.properties ?? {})).toEqual(['runId']);
96+
expect(schema.required).toEqual(['runId']);
97+
98+
// Runtime: AJV is configured with `removeAdditional: true`, so stray keys are silently
99+
// stripped from the input object in place.
100+
const input: Record<string, unknown> = { runId: 'run-widget-1', output: true };
101+
const ok = tool.ajvValidate(input);
102+
expect(ok).toBe(true);
103+
expect('output' in input).toBe(false);
104+
});
105+
106+
it('accepts a minimal runId payload', () => {
107+
const tool = getActorRunWidgetTool as HelperTool;
108+
const ok = tool.ajvValidate({ runId: 'run-widget-1' });
109+
expect(ok).toBe(true);
110+
});
111+
});

tests/unit/tools.mode_contract.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ describe('getCategoryTools mode contract (tool-mode separation)', () => {
4848
HelperTools.STORE_SEARCH_WIDGET,
4949
HelperTools.ACTOR_GET_DETAILS_WIDGET,
5050
HelperTools.ACTOR_CALL_WIDGET,
51+
HelperTools.ACTOR_RUNS_GET_WIDGET,
5152
]);
5253
});
5354

@@ -108,12 +109,17 @@ describe('getCategoryTools mode contract (tool-mode separation)', () => {
108109
});
109110

110111
describe('base data tools have no widget meta in either mode', () => {
111-
const baseToolNames = [HelperTools.ACTOR_GET_DETAILS, HelperTools.STORE_SEARCH, HelperTools.ACTOR_CALL];
112+
const baseTools: { name: HelperTools; category: keyof typeof defaultCategories }[] = [
113+
{ name: HelperTools.ACTOR_GET_DETAILS, category: 'actors' },
114+
{ name: HelperTools.STORE_SEARCH, category: 'actors' },
115+
{ name: HelperTools.ACTOR_CALL, category: 'actors' },
116+
{ name: HelperTools.ACTOR_RUNS_GET, category: 'runs' },
117+
];
112118
for (const mode of SERVER_MODES) {
113-
for (const name of baseToolNames) {
119+
for (const { name, category } of baseTools) {
114120
it(`${name} should have no ui/openai _meta keys in ${mode} mode`, () => {
115121
const categories = getCategoryTools(mode);
116-
const base = categories.actors.find((t) => t.name === name);
122+
const base = categories[category].find((t) => t.name === name);
117123
expect(base).toBeDefined();
118124
const meta = base!._meta ?? {};
119125
for (const key of Object.keys(meta)) {

0 commit comments

Comments
 (0)