feat: add VS Code behavior and source browsing

Show published behaviors and revision history, retrieve exact Rune source through the shared protocol, and cache read-only source documents by connection session and revision. Refresh behavior listings on image notifications and cover browsing against the real server and VS Code host.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-03 15:57:43 +01:00
parent e8c9fcad65
commit 39b7443c79
13 changed files with 409 additions and 14 deletions

View File

@ -6,6 +6,10 @@ All notable changes to this project will be documented in this file.
### Added
- VS Code behavior explorer with revision history, implementation identities,
and read-only Rune source documents isolated by connection and revision.
Image notifications refresh behavior listings without changing historical
source documents.
- VS Code actor spawning with explicit behavior and initial state, structured
message sending, pause/resume/stop/restart controls, and explicit scheduler
ticks. JSON inputs preserve i64 integers and `$actor`/`$record` markers.

View File

@ -141,7 +141,12 @@ open this repository and launch **Run Runa Extension** from Run and Debug
in the Extension Development Host. The **Runa Actors** view in Explorer lists
actors created through the REPL or another client. Click an actor to inspect
it. Server notifications automatically refresh the tree and open snapshots;
**Runa: Refresh Actors** is also available on demand.
**Runa: Refresh Image** is also available on demand.
The **Runa Behaviors** view shows published behaviors and their revision
history. Click a behavior for its current source, or expand it to open an older
revision. **Runa: Open Behavior Source** also offers a revision picker.
Source documents are read-only and remain unchanged when new revisions appear.
Use **Runa: Spawn Actor** to choose a behavior and supply JSON initial state.
Actor context menus offer structured message sending and pause/resume/stop/restart.

View File

@ -249,7 +249,10 @@ than replayed, and changes during a fetch trigger a follow-up refresh.
Cached open snapshots are marked disconnected on connection loss and isolated
by connection session so reconnecting to another image cannot overwrite them.
Spawn with explicit behavior/state, structured send, pause/resume/stop/restart,
and explicit scheduler ticks are also implemented. Remaining UX below is
explicit scheduler ticks, and a behavior explorer with revision history and
read-only source documents are also implemented. Sources are fetched at explicit
revisions and cached by connection session; image notifications refresh listings
without replacing historical source text. Remaining UX below is
planned; detailed runtime event subscriptions and
durable replay still need design.
@ -491,7 +494,7 @@ variables. Mise's `start` and `cli` tasks run the server and REPL respectively.
Connection management, the actor explorer, and read-only snapshot inspection
are implemented in `vscode/`, along with spawn/send, lifecycle controls, and
explicit scheduler ticks. Behavior editing,
explicit scheduler ticks, behavior/revision browsing, and immutable source documents. Behavior editing,
diagnostics, hot upgrades, and detailed runtime event subscriptions remain planned.
Non-destructive image-change notifications and automatic snapshot refresh are
implemented over the shared `runa-debug` WebSocket transport.

View File

@ -111,7 +111,11 @@ publishes the bundled `counter` behavior on image startup, not on connection.
The stdio transport and child-process client have been removed.
The TypeScript VS Code client issues `list_actors`, `inspect_actor`,
`get_behaviors`, `spawn`, `send`, `tick`, and pause/resume/stop/restart operations.
`get_behaviors`, `get_behavior_source`, `spawn`, `send`, `tick`, and
pause/resume/stop/restart operations.
Behavior source lookup uses an explicit immutable revision, not a mutable
latest-source alias. VS Code caches read-only source documents by connection,
behavior name, and revision while refreshing behavior listings on notifications.
It preserves i64 inputs and bounds the complete outgoing envelope as well as
incoming messages. It enforces loopback endpoints, bounded UTF-8 text messages,
version/request-ID/reply-kind checks, and portable snapshot value validation.

View File

@ -63,7 +63,7 @@ machine running the extension host, not necessarily the desktop.
The timestamp records when this client received the data, not a runtime clock.
Server `image_changed` notifications automatically update the tree and open
documents. Bursts are coalesced, and changes arriving during a fetch trigger
a follow-up fetch. **Refresh Actors** is also available on demand.
a follow-up fetch. **Refresh Image** is also available on demand.
- Open documents are retained and marked `connected: false` on disconnect.
Each connection gets distinct document URIs; reconnecting never replaces
snapshots from an earlier connection or another image. Closing a document
@ -86,6 +86,31 @@ natural JSON text, and resolves to the new reference. `runa.send` accepts
`runa.tick` accepts a positive integer tick count. Command failures are reported
in the Runa output channel and an error notification; commands are never retried.
## Behavior and source browsing
The **Runa Behaviors** view in Explorer lists the image's published behaviors.
Each item shows the current revision; expand it for revision history, newest
first. Tooltips show the implementation identity and the current revision is
marked. Click the behavior to open its current source, or a revision to open
that exact source. **Runa: Open Behavior Source** offers the same choices in
the Command Palette.
Sources are fetched through `get_behavior_source` and opened as read-only
`.rn` virtual documents, not local workspace files. If a Rune language
extension is installed, VS Code can associate its language support with `.rn`.
The source text is preserved exactly, without metadata headers. The URI identifies
the connection session, behavior name, and immutable revision. Documents remain
cached on disconnect and never turn into the latest revision automatically.
Reconnect opens distinct URIs; closing a source document releases its cache.
Cached sources are not persisted across VS Code restarts.
Server notifications refresh both explorers. **Runa: Refresh Image** does the
same on demand. Browsing does not publish code, install actor behavior, tick the
scheduler, or drain runtime events. Source editing and publication remain future
work. For automation, `runa.openBehaviorSource` accepts
`{ name: string, revision: string }` with a positive decimal revision and returns
the opened document URI.
## Development and validation
From the repository root:
@ -112,6 +137,6 @@ well as session isolation. On headless Linux:
xvfb-run -a mise run test-extension-host
```
Behavior/source browsing and editing,
Behavior editing and publication,
compilation diagnostics, detailed runtime event subscriptions, packaging for
distribution, and debugging remain future work.

View File

@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ImageClient } from "../src/client";
import { ProtocolError } from "../src/protocol";
import { control, startImage } from "./server";
test(
"behavior browsing retrieves immutable revision source and preserves connection after missing revisions",
{ timeout: 10000 },
async (t) => {
const image = await startImage();
let client: ImageClient | undefined;
t.after(async () => {
client?.close();
await image.stop();
});
client = await ImageClient.connect(image.address, (error) => assert.equal(error, undefined));
const original = await client.behaviorSource("counter", 1n);
assert.match(original, /pub fn handle/);
const source = "pub fn handle(state, message) { (state + 2, []) }\n";
const published = await control(image.address, {
operation: "publish_behavior",
parameters: { name: "counter", expected_revision: 1, source },
});
assert.match(JSON.stringify(published), /"kind":"behavior"/);
const behavior = (await client.behaviors()).find((behavior) => behavior.name === "counter");
assert.ok(behavior);
assert.equal(behavior.revision, 2n);
assert.deepEqual(
behavior.history.map((version) => version.revision),
[1n, 2n],
);
assert.equal(await client.behaviorSource("counter", 2n), source);
assert.equal(await client.behaviorSource("counter", 1n), original);
await assert.rejects(client.behaviorSource("counter", 3n), ProtocolError);
await assert.rejects(client.behaviorSource("missing", 1n), ProtocolError);
for (const revision of [0n, -1n, 18446744073709551616n]) {
await assert.rejects(client.behaviorSource("counter", revision), /positive u64/);
}
await assert.rejects(client.behaviorSource(" ", 1n), /behavior name/);
assert.equal(await client.behaviorSource("counter", 1n), original);
},
);

View File

@ -1,11 +1,14 @@
import assert from "node:assert/strict";
import * as vscode from "vscode";
import { control, spawnCounter, startImage } from "./server";
import { BehaviorTree, SourceDocuments } from "../src/behaviors";
import { ImageClient } from "../src/client";
export async function run(): Promise<void> {
const image = await startImage();
try {
await vscode.commands.executeCommand("runa.connect", image.address);
await browseBehaviors(image.address);
const spawned = await vscode.commands.executeCommand<unknown>("runa.spawn", {
behavior: "counter",
state: "5",
@ -111,6 +114,100 @@ export async function run(): Promise<void> {
}
}
async function browseBehaviors(address: string): Promise<void> {
const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined));
const tree = new BehaviorTree();
const sources = new SourceDocuments();
try {
const source = await client.behaviorSource("counter", 1n);
await control(address, {
operation: "publish_behavior",
parameters: {
name: "browse-test",
expected_revision: null,
source,
},
});
const listing = await client.behaviors();
tree.replace(listing);
const behavior = tree.getChildren().find((node) => node.name === "browse-test");
assert.ok(behavior);
assert.equal(
tree.getTreeItem(behavior).collapsibleState,
vscode.TreeItemCollapsibleState.Collapsed,
);
const version = tree.getChildren(behavior)[0];
assert.ok(version);
assert.equal(tree.getTreeItem(version).description, "current");
assert.deepEqual(tree.getChildren(version), []);
const item = tree.getTreeItem(behavior);
const opened = await vscode.commands.executeCommand<string>(
"runa.openBehaviorSource",
...item.command!.arguments!,
);
assert.ok(opened);
const original = vscode.window.activeTextEditor?.document;
assert.ok(original);
assert.equal(original.uri.scheme, "runa-source");
assert.match(original.uri.path, /\/1\.rn$/);
assert.equal(original.getText(), source);
const originalText = original.getText();
await vscode.commands.executeCommand("type", { text: "// cannot edit\n" });
assert.equal(original.getText(), originalText);
const newerSource = "pub fn handle(state, message) { (state + 2, []) }\n";
await control(address, {
operation: "publish_behavior",
parameters: {
name: "browse-test",
expected_revision: 1,
source: newerSource,
},
});
await vscode.commands.executeCommand("runa.refresh");
// Context-menu arguments are raw tree nodes, unlike item command arguments.
const updated = (await client.behaviors()).find((node) => node.name === "browse-test");
assert.ok(updated);
tree.replace([updated]);
assert.deepEqual(
tree.getChildren(updated).map((node) => node.revision),
[2n, 1n],
);
await vscode.commands.executeCommand("runa.openBehaviorSource", updated);
const newer = vscode.window.activeTextEditor?.document;
assert.ok(newer);
assert.equal(newer.getText(), newerSource);
assert.notEqual(newer.uri.toString(), original.uri.toString());
assert.equal(original.getText(), originalText);
const uri = sources.set(1, "name/with?#% spaces", 1n, originalText);
assert.equal(sources.provideTextDocumentContent(uri), originalText);
assert.notEqual(
sources.set(2, "name/with?#% spaces", 1n, newerSource).toString(),
uri.toString(),
);
assert.throws(() => sources.set(1, "name/with?#% spaces", 1n, newerSource), /Immutable/);
sources.remove(uri);
assert.throws(() => sources.provideTextDocumentContent(uri), /no longer cached/);
await vscode.commands.executeCommand("runa.disconnect");
assert.equal(original.getText(), originalText);
await vscode.commands.executeCommand("runa.connect", address);
await vscode.commands.executeCommand("runa.openBehaviorSource", {
name: "browse-test",
revision: "1",
});
const reconnected = vscode.window.activeTextEditor?.document;
assert.ok(reconnected);
assert.equal(reconnected.getText(), originalText);
assert.notEqual(reconnected.uri.toString(), original.uri.toString());
console.log(
"Runa behavior browsing: revision tree, immutable source, read-only documents, session isolation passed",
);
} finally {
client.close();
tree.dispose();
sources.dispose();
}
}
function documentChanged(
document: vscode.TextDocument,
expected: (text: string) => boolean,

View File

@ -13,7 +13,8 @@
],
"main": "./out/src/extension.js",
"activationEvents": [
"onView:runa.actors"
"onView:runa.actors",
"onView:runa.behaviors"
],
"scripts": {
"compile": "tsc -p .",
@ -37,6 +38,12 @@
},
"contributes": {
"commands": [
{
"command": "runa.openBehaviorSource",
"title": "Runa: Open Behavior Source",
"icon": "$(go-to-file)",
"enablement": "runa.connected"
},
{
"command": "runa.spawn",
"title": "Runa: Spawn Actor",
@ -83,7 +90,7 @@
},
{
"command": "runa.refresh",
"title": "Runa: Refresh Actors",
"title": "Runa: Refresh Image",
"icon": "$(refresh)"
},
{
@ -97,10 +104,19 @@
{
"id": "runa.actors",
"name": "Runa Actors"
},
{
"id": "runa.behaviors",
"name": "Runa Behaviors"
}
]
},
"viewsWelcome": [
{
"view": "runa.behaviors",
"contents": "Connect to a trusted local Runa image.\n[Connect](command:runa.connect)",
"when": "!runa.connected"
},
{
"view": "runa.actors",
"contents": "Connect to a trusted local Runa image.\n[Connect](command:runa.connect)",
@ -116,20 +132,25 @@
},
{
"command": "runa.connect",
"when": "view == runa.actors && !runa.connected",
"when": "(view == runa.actors || view == runa.behaviors) && !runa.connected",
"group": "navigation"
},
{
"command": "runa.refresh",
"when": "view == runa.actors && runa.connected",
"when": "(view == runa.actors || view == runa.behaviors) && runa.connected",
"group": "navigation"
},
{
"command": "runa.disconnect",
"when": "view == runa.actors && runa.connected"
"when": "(view == runa.actors || view == runa.behaviors) && runa.connected"
}
],
"view/item/context": [
{
"command": "runa.openBehaviorSource",
"when": "view == runa.behaviors && viewItem == runa.behavior",
"group": "inline"
},
{
"command": "runa.send",
"when": "view == runa.actors && viewItem == runa.actor",

91
vscode/src/behaviors.ts Normal file
View File

@ -0,0 +1,91 @@
import * as vscode from "vscode";
import { Behavior } from "./protocol";
export interface BehaviorRevision {
name: string;
revision: bigint;
implementation_id: string;
current: boolean;
}
export class BehaviorTree implements vscode.TreeDataProvider<Behavior | BehaviorRevision> {
private readonly changes = new vscode.EventEmitter<void>();
readonly onDidChangeTreeData = this.changes.event;
behaviors: Behavior[] = [];
replace(behaviors: Behavior[]): void {
this.behaviors = behaviors;
this.changes.fire();
}
getChildren(parent?: Behavior | BehaviorRevision): (Behavior | BehaviorRevision)[] {
if (!parent) return this.behaviors;
if (!("history" in parent)) return [];
return [...parent.history].reverse().map((version) => ({
name: parent.name,
...version,
current: version.revision === parent.revision,
}));
}
getTreeItem(behavior: Behavior | BehaviorRevision): vscode.TreeItem {
const root = "history" in behavior;
const item = new vscode.TreeItem(
root ? behavior.name : `Revision ${behavior.revision}`,
root ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None,
);
item.id = JSON.stringify(root ? [behavior.name] : [behavior.name, String(behavior.revision)]);
item.contextValue = "runa.behavior";
item.description = root
? `revision ${behavior.revision}`
: behavior.current
? "current"
: undefined;
item.tooltip = `Behavior: ${behavior.name}\nRevision: ${behavior.revision}\nImplementation: ${
root ? behavior.current_implementation_id : behavior.implementation_id
}`;
item.iconPath = new vscode.ThemeIcon(root ? "symbol-class" : "git-commit");
item.command = {
command: "runa.openBehaviorSource",
title: "Open Rune Source",
arguments: [{ name: behavior.name, revision: String(behavior.revision) }],
};
return item;
}
dispose(): void {
this.changes.dispose();
}
}
export class SourceDocuments implements vscode.TextDocumentContentProvider {
private readonly sources = new Map<string, string>();
set(session: number, name: string, revision: bigint, source: string): vscode.Uri {
const uri = vscode.Uri.from({
scheme: "runa-source",
authority: String(session),
path: `/behaviors/${encodeURIComponent(name)}/${revision}.rn`,
});
const previous = this.sources.get(uri.toString());
if (previous !== undefined && previous !== source) {
throw new Error("Immutable behavior revision changed source");
}
this.sources.set(uri.toString(), source);
return uri;
}
provideTextDocumentContent(uri: vscode.Uri): string {
const source = this.sources.get(uri.toString());
if (source === undefined)
throw new Error("Source is no longer cached; open the behavior revision again");
return source;
}
remove(uri: vscode.Uri): void {
this.sources.delete(uri.toString());
}
dispose(): void {
this.sources.clear();
}
}

View File

@ -123,6 +123,19 @@ export class ImageClient {
return reply.data;
}
async behaviorSource(name: string, revision: bigint): Promise<string> {
if (typeof name !== "string" || !name.trim()) throw new Error("Specify a behavior name");
if (typeof revision !== "bigint" || revision < 1n || revision > 18446744073709551615n) {
throw new Error("Behavior revision must be a positive u64 integer");
}
const reply = await this.request({
operation: "get_behavior_source",
parameters: { name, revision },
});
if (reply.kind !== "behavior_source") throw new Error("Expected behavior source");
return reply.data.source;
}
async spawn(behavior: string, state: Value, capacity = 64): Promise<string> {
if (!behavior.trim()) throw new Error("Specify a behavior to spawn");
if (!Number.isSafeInteger(capacity) || capacity < 1)

View File

@ -3,6 +3,7 @@ import { ImageClient, parseEndpoint } from "./client";
import { ActorSnapshot, formatSnapshot, LifecycleOperation } from "./protocol";
import { RefreshQueue } from "./refresh";
import { parseValue } from "./values";
import { BehaviorTree, SourceDocuments } from "./behaviors";
class ActorTree implements vscode.TreeDataProvider<ActorSnapshot> {
private readonly changes = new vscode.EventEmitter<void>();
@ -117,6 +118,11 @@ class SnapshotDocuments implements vscode.TextDocumentContentProvider {
export function activate(context: vscode.ExtensionContext): void {
const tree = new ActorTree();
const documents = new SnapshotDocuments();
const behaviors = new BehaviorTree();
const sources = new SourceDocuments();
const behaviorView = vscode.window.createTreeView("runa.behaviors", {
treeDataProvider: behaviors,
});
const view = vscode.window.createTreeView("runa.actors", { treeDataProvider: tree });
const output = vscode.window.createOutputChannel("Runa");
const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left);
@ -139,11 +145,13 @@ export function activate(context: vscode.ExtensionContext): void {
void vscode.commands.executeCommand("setContext", "runa.connected", connected);
status.text = connected ? "$(plug) Runa" : "$(debug-disconnect) Runa";
status.tooltip = connected
? `Connected to ${endpoint}. Click to refresh actors.`
? `Connected to ${endpoint}. Click to refresh the image.`
: "Connect to Runa";
status.command = connected ? "runa.refresh" : "runa.connect";
view.description = connected ? endpoint : "Disconnected";
view.message = connected ? undefined : "Not connected to an image.";
behaviorView.description = connected ? endpoint : "Disconnected";
behaviorView.message = connected ? undefined : "Not connected to an image.";
};
const disconnect = () => {
@ -155,6 +163,7 @@ export function activate(context: vscode.ExtensionContext): void {
session++;
previous?.close();
tree.replace([]);
behaviors.replace([]);
connectionStatus(false);
};
@ -163,8 +172,11 @@ export function activate(context: vscode.ExtensionContext): void {
const currentSession = session;
if (!current) throw new Error("Connect to a local image first");
const actors = await current.listActors();
const listing = await current.behaviors();
if (current !== client || currentSession !== session) return;
tree.replace(actors);
behaviors.replace(listing);
behaviorView.message = listing.length ? undefined : "No published behaviors.";
documents.refresh(session, endpoint, actors);
view.message = actors.length ? undefined : "No actors. Use Runa: Spawn Actor to create one.";
};
@ -204,6 +216,7 @@ export function activate(context: vscode.ExtensionContext): void {
client = undefined;
updates.dispose();
tree.replace([]);
behaviors.replace([]);
documents.disconnect(currentSession);
connectionStatus(false);
output.appendLine(`Disconnected from ${address}`);
@ -247,7 +260,7 @@ export function activate(context: vscode.ExtensionContext): void {
description: actor.status.status,
actorRef: actor.actor_ref,
})),
{ placeHolder: "Choose an actor to inspect (refresh to discover newly spawned actors)" },
{ placeHolder: "Choose an actor to inspect" },
);
if (!choice) return;
actorRef = choice.actorRef;
@ -302,6 +315,43 @@ export function activate(context: vscode.ExtensionContext): void {
},
});
const openBehaviorSource = async (selected?: { name: string; revision: string | bigint }) => {
const connection = active();
const currentSession = session;
let target = selected;
if (!target) {
const listing = await connection.client.behaviors();
connection.check();
const choice = await vscode.window.showQuickPick(
listing.flatMap((behavior) =>
[...behavior.history].reverse().map((version) => ({
label: behavior.name,
description: `revision ${version.revision}${version.revision === behavior.revision ? " (current)" : ""}`,
target: { name: behavior.name, revision: String(version.revision) },
})),
),
{ placeHolder: "Choose a behavior revision to view" },
);
target = choice?.target;
}
if (!target) return;
if (
(typeof target.revision !== "string" && typeof target.revision !== "bigint") ||
!/^[1-9]\d*$/.test(String(target.revision))
) {
throw new Error("Specify a positive decimal behavior revision");
}
connection.check();
const revision = BigInt(target.revision);
const source = await connection.client.behaviorSource(target.name, revision);
connection.check();
const uri = sources.set(currentSession, target.name, revision, source);
const document = await vscode.workspace.openTextDocument(uri);
connection.check();
await vscode.window.showTextDocument(document, { preview: true });
return uri.toString();
};
const spawn = async (options?: { behavior: string; state: string }) => {
const connection = active();
const behaviors = await connection.client.behaviors();
@ -383,16 +433,24 @@ export function activate(context: vscode.ExtensionContext): void {
context.subscriptions.push(
tree,
behaviors,
sources,
behaviorView,
documents,
view,
output,
status,
vscode.workspace.registerTextDocumentContentProvider("runa", documents),
vscode.workspace.onDidCloseTextDocument((document) => documents.remove(document.uri)),
vscode.workspace.registerTextDocumentContentProvider("runa-source", sources),
vscode.workspace.onDidCloseTextDocument((document) => {
documents.remove(document.uri);
sources.remove(document.uri);
}),
command("runa.connect", connect),
command("runa.disconnect", disconnect),
command("runa.refresh", refresh),
command("runa.inspect", inspect),
command("runa.openBehaviorSource", openBehaviorSource),
command("runa.spawn", spawn),
command("runa.send", send),
command("runa.stop", (selected?: { actor_ref: string }) => lifecycle("stop_actor", selected)),

View File

@ -43,6 +43,7 @@ export interface Behavior {
export type Command =
| { operation: "list_actors" | "get_behaviors" }
| { operation: "get_behavior_source"; parameters: { name: string; revision: bigint } }
| { operation: "inspect_actor" | LifecycleOperation; parameters: { actor_ref: string } }
| {
operation: "spawn";
@ -183,6 +184,7 @@ export type Reply =
| { kind: "behaviors"; data: Behavior[] }
| { kind: "actor_spawned"; data: { actor_ref: string } }
| { kind: "acknowledged" }
| { kind: "behavior_source"; data: { source: string } }
| { kind: "tick_completed"; data: { ticks: bigint } };
function decodeEnvelope(message: string): Record<string, unknown> {
@ -235,6 +237,9 @@ export function decodeResponse(line: string, requestId: string, command: Command
throw new ProtocolError(code, text(error.message));
}
const reply = object(result.Ok);
if (command.operation === "get_behavior_source" && reply.kind === "behavior_source") {
return { kind: "behavior_source", data: { source: text(object(reply.data).source) } };
}
if (command.operation === "get_behaviors" && reply.kind === "behaviors") {
const data = array(reply.data).map((input): Behavior => {
const behavior = object(input);

View File

@ -180,6 +180,32 @@ test("behavior listings validate names, revisions, history, and uniqueness", ()
);
});
test("behavior source replies require text and the matching operation", () => {
const command: Command = {
operation: "get_behavior_source",
parameters: {
name: "counter",
revision: 18446744073709551615n,
},
};
const source = "pub fn handle(state, message) {\n (state, []) // 雪\n}\n";
assert.deepEqual(
decodeResponse(envelope({ kind: "behavior_source", data: { source } }), "test", command),
{ kind: "behavior_source", data: { source } },
);
for (const data of [{}, { source: null }, { source: 12 }, { source: [] }]) {
assert.throws(() =>
decodeResponse(envelope({ kind: "behavior_source", data }), "test", command),
);
}
assert.throws(() => decodeResponse(envelope({ kind: "actors", data: [] }), "test", command));
assert.throws(() =>
decodeResponse(envelope({ kind: "behavior_source", data: { source } }), "test", {
operation: "get_behaviors",
}),
);
});
test("malformed states and snapshots fail instead of being coerced", () => {
for (const state of [
{ type: "integer", data: 9223372036854775808n },