Skip to content

Commit a387a2d

Browse files
jirispilkaclaude
andauthored
refactor: Split widget flag into base + widget response builders (#1077)
Part of #1066 (checkbox: split the `widget` boolean param). `buildStartRunResponse` and `buildGetActorRunSuccessResponse` each took a `widget` boolean and branched internally. Every caller passed a literal — widget tools `true`, everything else `false`/omitted — so the "widget only from `*-widget` tools" rule was enforced by a comment, not the type system. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016pXZvhEoE6Lz3DpBSL3n1Y --- _Generated by [Claude Code](https://claude.ai/code/session_016pXZvhEoE6Lz3DpBSL3n1Y)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent efed564 commit a387a2d

9 files changed

Lines changed: 116 additions & 75 deletions

src/resources/widgets.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,14 @@ export async function resolveAvailableWidgets(baseDir: string): Promise<Map<stri
182182
export function getWidgetConfig(uri: string): WidgetConfig | undefined {
183183
return WIDGET_REGISTRY[uri];
184184
}
185+
186+
/**
187+
* Actor-run widget `_meta`, with a per-run `openai/widgetDescription` merged in.
188+
* Shared by `buildStartRunWidgetResponse` and `buildGetActorRunWidgetResponse`.
189+
*/
190+
export function buildActorRunWidgetMeta(descriptionName: string): NonNullable<Resource['_meta']> {
191+
return {
192+
...(getWidgetConfig(WIDGET_URIS.ACTOR_RUN)?.meta ?? {}),
193+
'openai/widgetDescription': `Actor run progress for ${descriptionName}`,
194+
};
195+
}

src/tools/actors/actor_executor.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import log from '@apify/log';
33
import type { ActorExecutionParams, ActorExecutionResult, ActorExecutor } from '../../types.js';
44
import { getConsoleLinkContext } from '../../utils/console_link.js';
55
import { redactSkyfirePayId } from '../../utils/logging.js';
6-
import { buildGetActorRunSuccessResponse } from '../runs/get_actor_run.js';
6+
import { buildGetActorRunResponse } from '../runs/get_actor_run.js';
77
import { abortRunOnSignal, CALL_ACTOR_WAIT_SECS_DEFAULT, fetchActorRunData } from './actor_run_response.js';
88

99
/**
@@ -86,9 +86,8 @@ export const actorExecutor: ActorExecutor = {
8686
dataset.itemsSchema = { type: 'object', properties: params.datasetItemsSchema };
8787
}
8888

89-
return buildGetActorRunSuccessResponse({
89+
return buildGetActorRunResponse({
9090
...fetchResult.result,
91-
widget: false,
9291
linkContext: await getConsoleLinkContext(apifyClient.token, apifyClient),
9392
}) as ActorExecutionResult;
9493
},

src/tools/actors/actor_run_response.ts

Lines changed: 48 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import log from '@apify/log';
44

55
import type { ApifyClient } from '../../apify_client.js';
66
import { DATASET_SIZE_HINT_BYTES, HELPER_TOOLS, NARROW_OUTPUT_HINT } from '../../const.js';
7-
import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js';
7+
import { buildActorRunWidgetMeta } from '../../resources/widgets.js';
88
import type { ConsoleLinkContext } from '../../types.js';
99
import {
1010
buildConsoleDatasetUrl,
@@ -702,24 +702,11 @@ async function waitForRunWithProgress(opts: {
702702
// -----------------------------------------------------------------------------
703703

704704
/**
705-
* Build a RunResponse from an already-started ActorRun without waiting.
706-
* Used when waitSecs=0 (default and apps modes) and by widget variants that return immediately.
707-
* Storage metadata contains IDs only; pollers/widgets fetch updates via get-actor-run.
708-
*
709-
* Pass `widget: true` for widget-rendered responses: nextStep is replaced with a no-poll
710-
* message and widget _meta is included so the UI renders automatically.
711-
*
712-
* Invariant: `widget: true` is only valid from `*-widget` tools. Non-widget tools (call-actor,
713-
* direct actor tools) must omit it or pass `false`.
705+
* Shared construction for the immediate start response, used by both the base and widget
706+
* builders below. Returns the full `RunResponse` with the computed (non-widget) `nextStep`;
707+
* `buildStartRunWidgetResponse` overrides `nextStep` on its own copy.
714708
*/
715-
export function buildStartRunResponse(params: {
716-
actorName: string;
717-
actorRun: ActorRun;
718-
widget?: boolean;
719-
linkContext?: ConsoleLinkContext;
720-
}): ToolResponse {
721-
const { actorName, actorRun, widget, linkContext } = params;
722-
709+
function buildStartRunSharedContent(actorName: string, actorRun: ActorRun): RunResponse {
723710
// Start path returns before any metadata fetch, so every entry — default and aliases — is id-only.
724711
const datasetIds = buildStorageAliasIds(actorRun.storageIds?.datasets, actorRun.defaultDatasetId ?? undefined);
725712
const kvIds = buildStorageAliasIds(
@@ -733,15 +720,13 @@ export function buildStartRunResponse(params: {
733720
Object.fromEntries(Object.entries(kvIds).map(([alias, id]) => [alias, { id }])),
734721
);
735722

736-
const { summary, nextStep: computedNextStep } = buildStatusSummaryNextStep({
723+
const { summary, nextStep } = buildStatusSummaryNextStep({
737724
run: actorRun,
738725
dataset: datasets?.default,
739726
keyValueStore: keyValueStores?.default,
740727
});
741728

742-
const nextStep = widget ? WIDGET_NO_POLL_NEXT_STEP : computedNextStep;
743-
744-
const structuredContent: RunResponse = {
729+
return {
745730
runId: actorRun.id,
746731
actorId: actorRun.actId,
747732
actorName,
@@ -754,19 +739,50 @@ export function buildStartRunResponse(params: {
754739
summary,
755740
nextStep,
756741
};
742+
}
743+
744+
/**
745+
* Build a RunResponse from an already-started ActorRun without waiting.
746+
* Used when waitSecs=0 (default and apps modes).
747+
* Storage metadata contains IDs only; pollers fetch updates via get-actor-run.
748+
*/
749+
export function buildStartRunResponse(params: {
750+
actorName: string;
751+
actorRun: ActorRun;
752+
linkContext?: ConsoleLinkContext;
753+
}): ToolResponse {
754+
const { actorName, actorRun, linkContext } = params;
755+
756+
const structuredContent = buildStartRunSharedContent(actorName, actorRun);
757757
const consoleLinks = applyConsoleLinks(structuredContent, linkContext);
758758

759-
const widgetMeta = widget
760-
? {
761-
...(getWidgetConfig(WIDGET_URIS.ACTOR_RUN)?.meta ?? {}),
762-
'openai/widgetDescription': `Actor run progress for ${actorName}`,
763-
}
764-
: undefined;
759+
return respondOk(
760+
[
761+
JSON.stringify(structuredContent),
762+
`${structuredContent.summary}\n${structuredContent.nextStep}${consoleLinks}`,
763+
],
764+
{ structuredContent },
765+
);
766+
}
767+
768+
/**
769+
* Build a RunResponse from an already-started ActorRun for widget-rendered responses:
770+
* nextStep is replaced with a no-poll message and widget _meta is included so the UI renders
771+
* automatically. Used only by `*-widget` tools.
772+
*/
773+
export function buildStartRunWidgetResponse(params: { actorName: string; actorRun: ActorRun }): ToolResponse {
774+
const { actorName, actorRun } = params;
775+
776+
const base = buildStartRunSharedContent(actorName, actorRun);
777+
const structuredContent = { ...base, nextStep: WIDGET_NO_POLL_NEXT_STEP };
765778

766-
return respondOk([JSON.stringify(structuredContent), `${summary}\n${nextStep}${consoleLinks}`], {
767-
structuredContent,
768-
meta: widgetMeta,
769-
});
779+
return respondOk(
780+
[JSON.stringify(structuredContent), `${structuredContent.summary}\n${structuredContent.nextStep}`],
781+
{
782+
structuredContent,
783+
meta: buildActorRunWidgetMeta(actorName),
784+
},
785+
);
770786
}
771787

772788
// -----------------------------------------------------------------------------

src/tools/actors/call_actor.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import {
4242
import { classifyFailureCategory, extractAjvErrorDetails } from '../../utils/tool_status.js';
4343
import { extractActorId } from '../../utils/tools.js';
4444
import { actorNameToToolName, isActorBlockedUnderPaymentProvider } from '../actor_tool_naming.js';
45-
import { buildGetActorRunSuccessResponse } from '../runs/get_actor_run.js';
45+
import { buildGetActorRunResponse } from '../runs/get_actor_run.js';
4646
import { actorRunOutputSchema } from '../structured_output_schemas.js';
4747
import {
4848
abortRunOnSignal,
@@ -620,7 +620,7 @@ export async function executeCallActor(toolArgs: InternalToolArgs): Promise<Tool
620620
if ('error' in fetchResult) return fetchResult.error;
621621

622622
return {
623-
...buildGetActorRunSuccessResponse({ ...fetchResult.result, widget: false, linkContext }),
623+
...buildGetActorRunResponse({ ...fetchResult.result, linkContext }),
624624
toolTelemetry: { actorId: resolvedActorId },
625625
};
626626
} catch (error) {

src/tools/runs/get_actor_run.ts

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import dedent from 'dedent';
22
import { z } from 'zod';
33

44
import { HELPER_TOOLS } from '../../const.js';
5-
import { getWidgetConfig, WIDGET_URIS } from '../../resources/widgets.js';
5+
import { buildActorRunWidgetMeta } from '../../resources/widgets.js';
66
import type { ConsoleLinkContext, HelperTool, InternalToolArgs, ToolEntry, ToolInputSchema } from '../../types.js';
77
import { TOOL_TYPE } from '../../types.js';
88
import { compileSchema, fixZodSchemaRequired } from '../../utils/ajv.js';
@@ -86,25 +86,30 @@ export function buildGetActorRunError(runId: string, error: unknown): ToolRespon
8686

8787
/**
8888
* Build the success response. `content[0]` is the JSON-stringified `structuredContent`
89-
* mirror (per MCP spec); `content[1]` carries an LLM-readable narrative — `summary` +
90-
* `nextStep` in default mode, a short pointer in widget mode.
89+
* mirror (per MCP spec); `content[1]` carries an LLM-readable narrative of `summary` + `nextStep`.
9190
*/
92-
export function buildGetActorRunSuccessResponse(
93-
params: FetchActorRunResult & { widget: boolean; linkContext?: ConsoleLinkContext },
91+
export function buildGetActorRunResponse(
92+
params: FetchActorRunResult & { linkContext?: ConsoleLinkContext },
9493
): ToolResponse {
95-
const { run, structuredContent, widget, linkContext } = params;
96-
97-
if (!widget) {
98-
// Mints the `apifyConsoleUrl` fields onto structuredContent and returns the narrative suffix in one pass.
99-
const consoleLinks = applyConsoleLinks(structuredContent, linkContext);
100-
return respondOk(
101-
[
102-
JSON.stringify(structuredContent),
103-
`${structuredContent.summary}\n${structuredContent.nextStep}${consoleLinks}`,
104-
],
105-
{ structuredContent, meta: buildUsageMeta(run) },
106-
);
107-
}
94+
const { run, structuredContent, linkContext } = params;
95+
96+
// Mints the `apifyConsoleUrl` fields onto structuredContent and returns the narrative suffix in one pass.
97+
const consoleLinks = applyConsoleLinks(structuredContent, linkContext);
98+
return respondOk(
99+
[
100+
JSON.stringify(structuredContent),
101+
`${structuredContent.summary}\n${structuredContent.nextStep}${consoleLinks}`,
102+
],
103+
{ structuredContent, meta: buildUsageMeta(run) },
104+
);
105+
}
106+
107+
/**
108+
* Build the widget success response. `content[1]` carries a short pointer instead of the
109+
* summary/nextStep narrative. Used only by `*-widget` tools; does not apply console links.
110+
*/
111+
export function buildGetActorRunWidgetResponse(params: FetchActorRunResult): ToolResponse {
112+
const { run, structuredContent } = params;
108113

109114
// Override nextStep so the model reading structuredContent (content[0]) also sees no-poll guidance.
110115
const widgetContent = { ...structuredContent, nextStep: WIDGET_NO_POLL_NEXT_STEP };
@@ -116,9 +121,8 @@ export function buildGetActorRunSuccessResponse(
116121
{
117122
structuredContent: widgetContent,
118123
meta: {
119-
...(getWidgetConfig(WIDGET_URIS.ACTOR_RUN)?.meta ?? {}),
124+
...buildActorRunWidgetMeta(structuredContent.actorName ?? structuredContent.runId),
120125
...(buildUsageMeta(run) ?? {}),
121-
'openai/widgetDescription': `Actor run progress for ${structuredContent.actorName ?? structuredContent.runId}`,
122126
},
123127
},
124128
);
@@ -148,9 +152,8 @@ export const getActorRun: ToolEntry = Object.freeze({
148152
if ('aborted' in fetchResult) return respondAborted();
149153
if ('error' in fetchResult) return fetchResult.error;
150154

151-
return buildGetActorRunSuccessResponse({
155+
return buildGetActorRunResponse({
152156
...fetchResult.result,
153-
widget: false,
154157
linkContext: await getConsoleLinkContext(apifyToken, client),
155158
});
156159
} catch (error) {

src/tools/widgets/call_actor_widget.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { TOOL_TYPE } from '../../types.js';
1010
import { compileSchema } from '../../utils/ajv.js';
1111
import { respondServerError } from '../../utils/mcp.js';
1212
import { extractActorId } from '../../utils/tools.js';
13-
import { buildStartRunResponse } from '../actors/actor_run_response.js';
13+
import { buildStartRunWidgetResponse } from '../actors/actor_run_response.js';
1414
import {
1515
buildCallActorErrorResponse,
1616
callActorPreExecute,
@@ -120,7 +120,7 @@ export const callActorWidget: ToolEntry = Object.freeze({
120120
runId: actorRun.id,
121121
mcpSessionId: toolArgs.mcpSessionId,
122122
});
123-
const response = buildStartRunResponse({ actorName: baseActorName, actorRun, widget: true });
123+
const response = buildStartRunWidgetResponse({ actorName: baseActorName, actorRun });
124124
return {
125125
...response,
126126
toolTelemetry: { actorId: resolvedActorId },

src/tools/widgets/get_actor_run_widget.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { compileSchema } from '../../utils/ajv.js';
99
import { logHttpError } from '../../utils/logging.js';
1010
import { respondAborted } from '../../utils/mcp.js';
1111
import { fetchActorRunData } from '../actors/actor_run_response.js';
12-
import { buildGetActorRunError, buildGetActorRunSuccessResponse } from '../runs/get_actor_run.js';
12+
import { buildGetActorRunError, buildGetActorRunWidgetResponse } from '../runs/get_actor_run.js';
1313
import { actorRunOutputSchema } from '../structured_output_schemas.js';
1414

1515
/**
@@ -72,7 +72,7 @@ export const getActorRunWidget: ToolEntry = Object.freeze({
7272
if ('aborted' in fetchResult) return respondAborted();
7373
if ('error' in fetchResult) return fetchResult.error;
7474

75-
return buildGetActorRunSuccessResponse({ ...fetchResult.result, widget: true });
75+
return buildGetActorRunWidgetResponse({ ...fetchResult.result });
7676
} catch (error) {
7777
logHttpError(error, 'Failed to get Actor run (widget)', { runId: parsed.runId });
7878
return buildGetActorRunError(parsed.runId, error);

tests/unit/tools.get_actor_run.response.test.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
33

44
import {
55
buildStartRunResponse,
6+
buildStartRunWidgetResponse,
67
buildStatusSummaryNextStep,
78
collapseArrayIndices,
89
type RunDataset,
@@ -647,16 +648,18 @@ describe('get-actor-run default response', () => {
647648
// buildStartRunResponse — the "fire and forget" (waitSecs=0) response builder
648649
// -----------------------------------------------------------------------------
649650

650-
describe('buildStartRunResponse()', () => {
651-
const actorRun = {
652-
id: 'run-abc',
653-
actId: 'actor-xyz',
654-
status: 'RUNNING',
655-
startedAt: new Date('2026-01-02T03:04:05.000Z'),
656-
defaultDatasetId: 'dataset-abc',
657-
defaultKeyValueStoreId: 'kv-abc',
658-
} as unknown as ActorRun;
651+
// Shared by buildStartRunResponse() and buildStartRunWidgetResponse() below — both builders
652+
// consume the same ActorRun shape.
653+
const actorRun = {
654+
id: 'run-abc',
655+
actId: 'actor-xyz',
656+
status: 'RUNNING',
657+
startedAt: new Date('2026-01-02T03:04:05.000Z'),
658+
defaultDatasetId: 'dataset-abc',
659+
defaultKeyValueStoreId: 'kv-abc',
660+
} as unknown as ActorRun;
659661

662+
describe('buildStartRunResponse()', () => {
660663
it('builds correct RunResponse shape without widget metadata', () => {
661664
const result = buildStartRunResponse({ actorName: 'apify/rag-web-browser', actorRun });
662665

@@ -730,12 +733,13 @@ describe('buildStartRunResponse()', () => {
730733
expect(content[1].text).toContain('Apify Console: run https://console.apify.com/actors/runs/run-abc');
731734
expect(content[1].text).toContain(VERBATIM_LINKS_NUDGE);
732735
});
736+
});
733737

734-
it('includes widget metadata and no-poll nextStep when widget=true', () => {
735-
const result = buildStartRunResponse({
738+
describe('buildStartRunWidgetResponse()', () => {
739+
it('includes widget metadata and a no-poll nextStep', () => {
740+
const result = buildStartRunWidgetResponse({
736741
actorName: 'apify/rag-web-browser',
737742
actorRun,
738-
widget: true,
739743
});
740744

741745
const { structuredContent, _meta } = result as {

tests/unit/tools.get_actor_run.widget.response.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ const MOCK_RUN_RUNNING = {
1818
status: 'RUNNING',
1919
startedAt: new Date('2026-04-20T12:00:00.000Z'),
2020
stats: { runTimeSecs: 5, computeUnits: 0.01, memMaxBytes: 1024 },
21+
usageTotalUsd: 0.0002,
22+
usageUsd: { ACTOR_COMPUTE_UNITS: 0.0002 },
2123
};
2224

2325
const MOCK_ACTOR = {
@@ -48,6 +50,7 @@ describe('get-actor-run-widget response', () => {
4850
_meta?: {
4951
ui?: { resourceUri?: string; visibility?: readonly string[]; csp?: unknown };
5052
'openai/widgetDescription'?: string;
53+
'com.apify/ActorRun'?: { usageTotalUsd?: number; usageUsd?: unknown };
5154
};
5255
};
5356

@@ -71,6 +74,11 @@ describe('get-actor-run-widget response', () => {
7174
expect(_meta?.ui?.visibility).toEqual(['model', 'app']);
7275
expect(_meta?.ui?.csp).toBeDefined();
7376
expect(_meta?.['openai/widgetDescription']).toContain('apify/rag-web-browser');
77+
// Widget _meta also carries run usage metadata (buildUsageMeta), alongside widget-specific meta.
78+
expect(_meta?.['com.apify/ActorRun']).toEqual({
79+
usageTotalUsd: 0.0002,
80+
usageUsd: { ACTOR_COMPUTE_UNITS: 0.0002 },
81+
});
7482
});
7583

7684
it('carries widget _meta on the tool definition', () => {

0 commit comments

Comments
 (0)