Files
openclaw/src/node-host/node-invoke-progress.test.ts
Peter Steinberger d8d2f83cc1 feat(terminal): open Codex/Claude catalog sessions in a terminal on their owning host
Catalog session rows (sidebar context menu + click), the built-in viewer
header, and a new "Open Codex/Claude sessions in" preference can launch the
native CLI (codex resume / claude --resume) in the operator terminal on the
machine that owns the session.

- Gateway-local sessions spawn through the existing terminal launch policy
  (sandbox/enabled gates preserved) with the resume command in the session cwd.
- Paired-node sessions run through a new seq-ordered node PTY relay: a
  duplex node-host command streams PTY output via node.invoke.progress and
  receives keystrokes/resize via a new node.invoke.input event, behind the
  unchanged terminal.* client protocol (TerminalSessionManager gains a backend
  abstraction; node relay reuses the streaming-invoke controller).
- Owner boundary: each plugin owns its resume command and builds argv from a
  validated thread id; the gateway routes node opens through the node command
  allowlist and plugin invoke policy (no advertisement-only trust), and nodes
  re-verify session eligibility before spawning.
- UI setting catalogOpenTarget + canOpenTerminal capability gate every entry
  point; capability requires the owning host to actually have the CLI.

Node PATH is normalized before command-availability probes, Windows .cmd/.bat
shims spawn via ComSpec, and catalog terminal opens reattach persisted tabs
before opening the new tab.
2026-07-13 22:20:50 -07:00

65 lines
2.0 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { NodeHostClient } from "./client.js";
import {
createNodeInvokeProgressWriter,
resolveNodeInvokeHeartbeatInterval,
} from "./node-invoke-progress.js";
const frame = {
id: "invoke-1",
nodeId: "node-1",
command: "test.duplex",
paramsJSON: null,
timeoutMs: 0,
idempotencyKey: null,
};
describe("node invoke progress writer", () => {
it("chunks output to 16 KiB and pauses its producer for backpressure", async () => {
const request = vi.fn(async () => ({}));
const client = { request } as NodeHostClient;
const pausable = { pause: vi.fn(), resume: vi.fn() };
const writer = createNodeInvokeProgressWriter({
client,
frame,
idleTimeoutMs: 30_000,
onError: vi.fn(),
});
await writer.write("é".repeat(10_000), pausable);
expect(request).toHaveBeenCalledTimes(2);
expect(pausable.pause).toHaveBeenCalledOnce();
expect(pausable.resume).toHaveBeenCalledOnce();
for (const [, params] of request.mock.calls as unknown as Array<[string, { chunk: string }]>) {
expect(Buffer.byteLength(params.chunk, "utf8")).toBeLessThanOrEqual(16 * 1024);
}
});
it("emits idle heartbeats at the clamped half-timeout interval", async () => {
vi.useFakeTimers();
try {
const request = vi.fn(async () => ({}));
const writer = createNodeInvokeProgressWriter({
client: { request } as NodeHostClient,
frame,
idleTimeoutMs: 2_000,
onError: vi.fn(),
});
writer.startHeartbeats();
await vi.advanceTimersByTimeAsync(1_000);
expect(request).toHaveBeenCalledWith("node.invoke.progress", {
invokeId: "invoke-1",
nodeId: "node-1",
seq: 0,
chunk: "",
});
writer.stop();
await writer.flush();
} finally {
vi.useRealTimers();
}
expect(resolveNodeInvokeHeartbeatInterval(100)).toBe(250);
expect(resolveNodeInvokeHeartbeatInterval(60_000)).toBe(5_000);
});
});