Skip to content

feat: Allow action callbacks to return a result value#217

Merged
Julusian merged 3 commits into
bitfocus:mainfrom
jswalden:action-callback-return-result
May 23, 2026
Merged

feat: Allow action callbacks to return a result value#217
Julusian merged 3 commits into
bitfocus:mainfrom
jswalden:action-callback-return-result

Conversation

@jswalden
Copy link
Copy Markdown
Contributor

@jswalden jswalden commented Apr 2, 2026

This is the @companion-module/{base,host} side of an implementation of a context.setResult(value) function, added for use in action callbacks, where the supplied value is then propagated into the next action in sequence as $(this:result).

It might further be worth adding to CompanionActionDefinition an "exemplar" result value. Button and expression previews obviously can't know what a preceding action actually returned. It might be useful to have a sample value that can be fed into those preview mechanisms, in order to get somewhat better preview behavior than you get when $(this:result) is not the structured value you expect it to be. But I did not implement this.

The only way I could figure out how to test this locally, in Companion, was to hard-code in file: module versions that mean this isn't immediately landable. Those would need to change for this to be landed.

Summary by CodeRabbit

  • New Features

    • Actions may declare typed results; callbacks can return those results.
    • Hosted action metadata now indicates whether an action yields a result, and action execution returns that result when present.
  • Bug Fixes

    • Action execution responses are stricter: successful responses include an explicit result field (or undefined when none), failures always include an error message.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 2, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Introduces explicit action result typing: schemas split into with-result / no-result variants, action-definition callbacks refactored to reflect return types, host execution/result shapes made discriminated unions, and host action metadata now includes hasResult.

Changes

Cohort / File(s) Summary
Action schema & definitions
packages/base/src/module-api/action.ts
Added CompanionActionSchemaOptions, CompanionActionSchemaWithResult, CompanionActionSchemaNoResult; made CompanionActionSchema<TOptions, TResult> conditional; refactored action definition types into base / WithResult / NoResult and conditional CompanionActionDefinition<TSchema>; added type-only JsonValue / type-testing imports and internal type assertions.
Module API surface types
packages/base/src/module-api/base.ts, packages/base/src/module-api/preset/definition.ts
Widened action manifest/instance typings to accept a union of CompanionActionSchemaNoResult<...>
Host runtime & types
packages/host/src/instance.ts, packages/host/src/internal/actions.ts, packages/host/src/context.ts
Replaced ExecuteActionResult with discriminated union ExecuteActionSuccess { success: true; result } / ExecuteActionFailure { success: false; errorMessage }; ActionManager stores schema union and returns result on success when hasResult; added hasResult: boolean to HostActionDefinition.
Dev deps
packages/base/package.json
Added type-testing as a new devDependency (no runtime changes).

Poem

✨ Schemas split with tidy care,
Callbacks state what they will bear.
Hosts now know if results appear,
Success or fail — the signs are clear.
Types sing softly: all is fair.

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main feature being added—enabling action callbacks to return result values—which aligns perfectly with the PR's core objective.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/host/src/internal/actions.ts (1)

63-63: Nice touch on capturing the result! The initialization at line 63 is already type-safe since CompanionVariableValue already includes undefined. Adding an explicit initializer like = undefined is a nice-to-have for clarity—it makes the "not set" default more obvious to readers, especially if your TypeScript configuration is strict about definite assignment.

Optional improvement
-		let result: CompanionVariableValue
+		let result: CompanionVariableValue | undefined = undefined

Either way, the code works great as-is. Thanks for this addition!


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 73207dac-f006-407e-918c-20282a31ddae

📥 Commits

Reviewing files that changed from the base of the PR and between a08ce52 and e3070d9.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (6)
  • packages/base/src/module-api/action.ts
  • packages/base/src/module-api/base.ts
  • packages/base/src/module-api/preset/definition.ts
  • packages/host/package.json
  • packages/host/src/instance.ts
  • packages/host/src/internal/actions.ts

Comment thread packages/base/src/module-api/action.ts Outdated
Comment thread packages/host/package.json Outdated
@jswalden jswalden force-pushed the action-callback-return-result branch from e3070d9 to 3f0f0bb Compare April 2, 2026 00:48
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b0616082-14bc-441d-adf3-6d0bf56e2008

📥 Commits

Reviewing files that changed from the base of the PR and between e3070d9 and 3f0f0bb.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (6)
  • packages/base/src/module-api/action.ts
  • packages/base/src/module-api/base.ts
  • packages/base/src/module-api/preset/definition.ts
  • packages/host/package.json
  • packages/host/src/instance.ts
  • packages/host/src/internal/actions.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/host/package.json
  • packages/host/src/internal/actions.ts
  • packages/base/src/module-api/base.ts
  • packages/base/src/module-api/preset/definition.ts

Comment thread packages/base/src/module-api/action.ts Outdated
@jswalden jswalden force-pushed the action-callback-return-result branch from 3f0f0bb to f081104 Compare April 2, 2026 02:32
@jswalden
Copy link
Copy Markdown
Contributor Author

jswalden commented Apr 2, 2026

This will fix #216.

@jswalden jswalden changed the title feat: Allow action callbacks to specify a result value, that is passed to the next sequential action as $(this:result). (#216) feat: Allow action callbacks to specify a result value, that is passed to the next sequential action as $(this:result) Apr 2, 2026
@jswalden jswalden force-pushed the action-callback-return-result branch from f081104 to 49b9666 Compare April 2, 2026 21:28
@Julusian
Copy link
Copy Markdown
Member

Julusian commented Apr 2, 2026

I think the DX will be nicer if we take the return value of the callback instead of a method on the context. That will also make it clearer that this value must be provided before the method returns, and can't be provided after.

As I said in the companion pr, I think that the action definition should also have a boolean property so that the action declares whether it does/might produce a value. so that we can provide some visual hints (either now or in the future)

I'm also wondering about the types, but I think that I just need to have a play

@jswalden
Copy link
Copy Markdown
Contributor Author

jswalden commented Apr 3, 2026

I think the DX will be nicer if we take the return value of the callback instead of a method on the context. That will also make it clearer that this value must be provided before the method returns, and can't be provided after.

Oh, absolutely. But it's technically a breaking change, callbacks that returned something before abruptly will be exposing it. That's honestly the only reason I didn't make it work that way here.

As I said in the companion pr, I think that the action definition should also have a boolean property so that the action declares whether it does/might produce a value. so that we can provide some visual hints (either now or in the future)

If an exemplar value were added, then we could type-check that for typeof exemplar !== 'undefined'. Or there's this boolean approach.

I guess it's nice that this half of the thing, is somewhat independent of the user-exposed portion of this that can be finagled however, mostly separately.

@jswalden
Copy link
Copy Markdown
Contributor Author

jswalden commented Apr 3, 2026

Oh, I guess if there's a boolean on offer, you could use that to decide whether to cast the return value to the result type or drop it, which would be treating it tantamount to the void already on it. Yeah, maybe it is possible to backwards-compatibly futz this all into workingness...

@Julusian
Copy link
Copy Markdown
Member

Julusian commented Apr 4, 2026

If an exemplar value were added, then we could type-check that for typeof exemplar !== 'undefined'. Or there's this boolean approach.

true, I suppose that an example value could be nice. But I'm hesistant to do any casting of the return value, as we often won't be able to (having done this for action/feedback options now as part of the builtin parsing).

But maybe a boolean is a good thing to have to enable producing any return value, and an optional example or some definition of the return type (could just be 'number') could be a nice extra for those who want it. But yeah, otherwise that example/definition could be required, and used to enable the functionality. Then it can take the return value

@jswalden jswalden force-pushed the action-callback-return-result branch from 49b9666 to 6ece8c1 Compare April 4, 2026 23:22
@jswalden
Copy link
Copy Markdown
Contributor Author

jswalden commented Apr 6, 2026

The value of an example value would be -- if the other half of this were still exposing action results by $(this:result) -- to substitute it into expression preview streams. But now that the other half of this is just setting into local/custom variables, there's no value in an exemplar value any more.

@Julusian
Copy link
Copy Markdown
Member

Julusian commented Apr 6, 2026

I've done a bit of fiddling with the types.
let me know what you think

I'm thinking that accepting a small breaking change in CompanionActionDefinition so that it expected TSchema instead of TOptions is best, partly to unify it with feedbacks, and to prepare in case we need to do anything more in future. better to sneak it in now when it will affect like 10 modules, instead of either battling the types in a years time, or having to do a major version of the types.

In behringer-x32, it was one line to make it happy, homeassistant-server needs no changes.

I was wondering about condensing the schema types, but after playing it does seem that split like this is better.

@jswalden
Copy link
Copy Markdown
Contributor Author

jswalden commented Apr 7, 2026

FWIW there are some type-manipulation problems here (one that you introduced, but I think also one that I inadvertently introduced before anything you did!), and I'm working on fixing them. More when I've finished that.

Comment thread packages/base/src/module-api/action.ts Outdated
? Result extends JsonValue
? CompanionActionDefinitionCallbackWithResult<Options, Result>
: CompanionActionDefinitionCallbackNoResult<Options>
: never
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find myself periodically infuriated with TypeScript's fairly careless disregard for how generics deal with types, versus unions of types, and how inserting an X extends any is the inscrutable syntax that breaks X into its constituent types while [X] extends [T] is how you avoid distributing while still doing the testing. Really seems like there should be much clearer, explicitly-distributive syntax. (Or nondistributive, I don't necessarily care about specifics, just they should use distinct syntax!)

That grump is relevant because something in the above causes Result to be correspondingly broken up (I think everything nested under a X extends T will have X as a union member) -- resulting in callback not being (...) => JsonValue | Promise<JsonValue> but instead (...) => string | number | null | ... | Promise<string> | Promise<number> | .... And then a callback that returns not one of the types within the JsonValue union, but a union of multiple of them, will fail to compile, as for action definitions in this test module:

		return_value: {
			name: 'Return a value',
			options: [
				{
					id: 'val-to-return',
					type: 'textinput',
					label: 'Enter a JSON string to return its parsed value',
					default: 'null',
					useVariables: true,
				},
			],
			hasResult: true,
			callback: ({ options }): JsonValue => {
				return JSON.parse(options['val-to-return'])
			},
		},

Type '({ options }: CompanionActionEvent<{ 'val-to-return': string; }>) => JsonValue' is not assignable to type '((action: CompanionActionEvent<{ 'val-to-return': string; }>, context: CompanionActionContext) => Promise | null) | ((action: CompanionActionEvent<{ 'val-to-return': string; }>, context: CompanionActionContext) => string | Promise) | ((action: CompanionActionEvent<{ 'val-to-return': string; }>, context: CompanionActionContext) => number | Promise) | ((action: CompanionActionEvent<{ 'val-to-return': string; }>, context: CompanionActionContext) => false | Promise) | ((action: CompanionActionEvent<{ 'val-to-return': string; }>, context: CompanionActionContext) => true | Promise) | ((action: CompanionActionEvent<{ 'val-to-return': string; }>, context: CompanionActionContext) => JsonObject | Promise) | ((action: CompanionActionEvent<{ 'val-to-return': string; }>, context: CompanionActionContext) => JsonValue[] | Promise<JsonValue[]>) | ((action: CompanionActionEvent<{ 'val-to-return': string; }>, context: CompanionActionContext) => readonly JsonValue[] | Promise<readonly JsonValue[]>)'.
Type '({ options }: CompanionActionEvent<{ 'val-to-return': string; }>) => JsonValue' is not assignable to type '(action: CompanionActionEvent<{ 'val-to-return': string; }>, context: CompanionActionContext) => Promise | null'.
Type 'JsonValue' is not assignable to type 'Promise | null'.
Type 'string' is not assignable to type 'Promise'.ts(2322)
action.d.ts(82, 5): The expected type comes from property 'callback' which is declared here on type 'CompanionActionDefinitionCallbackWithResult<{ 'val-to-return': string; }, null> | CompanionActionDefinitionCallbackWithResult<{ 'val-to-return': string; }, string> | CompanionActionDefinitionCallbackWithResult<{ 'val-to-return': string; }, number> | CompanionActionDefinitionCallbackWithResult<{ 'val-to-return': string; }, false> | CompanionActionDefinitionCallbackWithResult<{ 'val-to-return': string; }, true> | CompanionActionDefinitionCallbackWithResult<{ 'val-to-return': string; }, JsonObject> | CompanionActionDefinitionCallbackWithResult<{ 'val-to-return': string; }, JsonValue[]> | CompanionActionDefinitionCallbackWithResult<{ 'val-to-return': string; }, readonly JsonValue[]>'

I created this goof for myself while working on the patch at one point, so it's easy to recognize. 🙂

This also reveals a failure of my own here: JsonValue | void is already a union, and there is no way to preserve JsonValue (or a union result extending it) without consistently using [TResult] extends [JsonValue] and similar to do testing on it. Which means that there's no letting JsonValue | void create a type that distributes into CAD<Options, JsonValue> | CAD<Options, void>. So every place that says CAD<Options, JsonValue | void> has to be changed to CAD<Options, JsonValue> | CAD<Options, void>. Or, as I prefer to overloading, CADWithResult<Options, JsonValue> | CADNoResult<Options> and similar. Passing through the most-generic layer where the result parameter is optional just feels more complicated to me than using the generics specific to result/no-result varieties.

The update I pushed will fix all this, and works with my test module. However, you may want to more deftly handle n/no-missing-import as happens with the type-testing helper module I added to perform tests -- since I presume you would be squashing/landing all this, if you have preferences for how best to handle that I'll let you implement them.

Copy link
Copy Markdown
Member

@Julusian Julusian May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe the type-testing stuff should be pulled out into a separate file, to make it clearer that it is test code?
not sure it is worth it

The reset of the comment about type unions here sounds familiar from battles Ive had in the past, and all I remember on this today is they are never fun to fix

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is, of course, a matter of taste. But for testing entirely in the type system without impact on runtime code, I think it's generally desirable to keep the tests as close to the actual code being tested as possible. It's more readable to see the tests specifically of types, be specifically tested in the type system adjacent. After all, tsc boils it away during compilation, unlike testing code that actually does stuff at runtime.

Comment thread packages/host/src/internal/actions.ts Outdated
readonly #actionDefinitions = new Map<string, CompanionActionDefinition>()
readonly #actionDefinitions = new Map<
string,
CompanionActionDefinition<CompanionActionSchema<CompanionOptionValues, any>>
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if you use JsonValue | void instead of any, at least with my CAD definition techniques this expands to CompanionActionDefinition<CompanionActionSchema<CompanionOptionValues, JsonValue> | CompanionActionSchema<CompanionOptionValues, void>> i.e. CAD<CAS<COV, JsonValue>> | CAD<CAS<COV, void>>. I think there is a possibility you're using any here because that doesn't result in a union being prematurely exploded on you.

This is gnarly enough that the expansion probably ought be actually verified. My preferred way would be to use type-testing to write out the type expectations and guarantee their enforcement at compile time, as I've done in the modules I work in -- although the first-pass hacking-in I did in the latest commit pushed here plays fast and loose importing 'type-testing' in ways you probably want to clean up. (I had to go out of my way to turn off n/no-missing-import for this Node module, and the eslint rule thing probably needs dealing with too.)

@jswalden jswalden changed the title feat: Allow action callbacks to specify a result value, that is passed to the next sequential action as $(this:result) feat: Allow action callbacks to return a result value Apr 7, 2026
@jswalden
Copy link
Copy Markdown
Contributor Author

jswalden commented Apr 7, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 7, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e265d5dc-7fc9-494f-9f28-57c6d65dc2d2

📥 Commits

Reviewing files that changed from the base of the PR and between 49b9666 and 8bfaedc.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (7)
  • packages/base/package.json
  • packages/base/src/module-api/action.ts
  • packages/base/src/module-api/base.ts
  • packages/base/src/module-api/preset/definition.ts
  • packages/host/src/context.ts
  • packages/host/src/instance.ts
  • packages/host/src/internal/actions.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/base/package.json
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/host/src/context.ts
  • packages/host/src/internal/actions.ts
  • packages/base/src/module-api/base.ts

Comment thread packages/base/src/module-api/action.ts Outdated
@jswalden jswalden force-pushed the action-callback-return-result branch from 8bfaedc to c868739 Compare April 21, 2026 00:25
@Julusian Julusian force-pushed the action-callback-return-result branch 3 times, most recently from 505b261 to 70772cf Compare May 1, 2026 21:17
@Julusian
Copy link
Copy Markdown
Member

Julusian commented May 1, 2026

ok, Im happy enough with this to merge it.

I am not keen on how much the CompanionActionSchemaNoResult<CompanionOptionValues> | CompanionActionSchemaWithResult<CompanionOptionValues, JsonValue> is repeated, but I guess that can't be avoided?

I have pushed 70772cf here which I don't think breaks anything, and would be nice to keep that place simpler. I think using these stronger typings could already be a bit overwhelming, and presenting users with that as a starting point for their stronger typings will probably really confuse some

@jswalden jswalden force-pushed the action-callback-return-result branch from 70772cf to 848be40 Compare May 4, 2026 00:33
@jswalden
Copy link
Copy Markdown
Contributor Author

jswalden commented May 4, 2026

I am not keen on how much the CompanionActionSchemaNoResult<CompanionOptionValues> | CompanionActionSchemaWithResult<CompanionOptionValues, JsonValue> is repeated, but I guess that can't be avoided?

You could add an export type CompanionAnyActionSchema = ... and use that everywhere. But then you're just trading repetition for lack of clarity in documentation. Does not seem worthwhile to me, YMMV.

I have pushed 70772cf here which I don't think breaks anything, and would be nice to keep that place simpler.

This breaks a module's instance types that want to describe an action that has a result.

I updated everything to rebase it onto main and #225. (I also had to force module version to 2.1.0-0-nightly-main-20260501-215220-309fb87 in a rev on top of this to actually make stuff work in my dev environment, a change not included in this PR/its single rev.) I made minor tweaks to names to attempt to draw it all into naming consistency with the subscribe hooks stuff, as far as NoX/WithoutX goes. I also tried to add documentation explaining prosaically what the expected action definitions will look like, as typedoc can expose the resulting type so readably when you start doing intersections like this all does. I think I've kept it all at a "happy enough with this to merge it" standard -- roughly -- but I presume this still is grody enough to want a look over still.

@jswalden jswalden force-pushed the action-callback-return-result branch from 848be40 to 64abb22 Compare May 5, 2026 19:28
@jswalden jswalden force-pushed the action-callback-return-result branch from 64abb22 to cf25c4f Compare May 19, 2026 18:00
@jswalden
Copy link
Copy Markdown
Contributor Author

I rebased against main and added a second rev that undoes the API-breaking changes previously added under the theory that API could be broken while only a very few modules had updated to the 2.0.0 API. My sense is that, after a month and a half and however many more modules updating to 2.0.0, we can't quite swing that any more. If my sense is wrong, it's easy to accept only the first rev and discard the second.

@Julusian
Copy link
Copy Markdown
Member

Im not worried about that slightly breaking change, its still only 23 modules potentially affected, and its a pretty trivial change to adopt.

I have been wondering if I should correct that in a patch of 2.0, but I'm not sure if that is a good idea

@Julusian Julusian merged commit 95c00ff into bitfocus:main May 23, 2026
3 checks passed
@jswalden jswalden deleted the action-callback-return-result branch May 25, 2026 06:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants