mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 02:32:47 +00:00
fix(client): preserve workspace and agent creation on older hosts (#4895)
* fix(client): preserve creation on older hosts Creation keys must not disable workspace and agent creation when the host lacks request receipts. Keep capability selection inside the client and degrade to the original RPCs with the initial prompt intact. * test(creation): own legacy daemon cleanup in fixtures * fix(client): preserve original prompted creation on legacy hosts Legacy receipt support does not preserve create-and-prompt semantics when split into a later send. Reuse the original creation RPC so naming, creation context and startup receive the same input as before.
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
/* eslint-disable no-empty-pattern -- Playwright reads fixture dependencies from destructured parameters. */
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { metroTest, expect } from "../support/fixtures";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { withProjectOwnership } from "../support/helpers/project-ownership";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { buildSeededHost, buildCreateAgentPreferences } from "../support/helpers/daemon-registry";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
openNewWorkspaceComposer,
|
||||
selectWorkspaceIsolation,
|
||||
loadSessionMessageReaders,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { fillComposerDraft } from "../support/helpers/composer";
|
||||
import { pressSubmitBeforeTheNextRender } from "../support/helpers/creation";
|
||||
import { createAgentTabFromMenu } from "../support/helpers/workspace-tabs";
|
||||
|
||||
for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
let daemon: Awaited<ReturnType<typeof startIsolatedHostDaemon>>;
|
||||
const test = metroTest.extend<{
|
||||
client: DaemonClient;
|
||||
gate: Awaited<ReturnType<typeof holdLegacyAgentCreation>>;
|
||||
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
|
||||
project: Awaited<ReturnType<typeof seedWorkspace>>;
|
||||
}>({
|
||||
gate: async ({ page }, provide) => {
|
||||
const gate = await holdLegacyAgentCreation(page, daemon.port);
|
||||
await provide(gate);
|
||||
gate.release();
|
||||
},
|
||||
client: async ({}, provide) => {
|
||||
const client = withProjectOwnership(
|
||||
await connectDaemonClient<DaemonClient>({
|
||||
clientIdPrefix: "legacy-creation",
|
||||
port: daemon.port,
|
||||
}),
|
||||
);
|
||||
await provide(client);
|
||||
await client.close();
|
||||
},
|
||||
repo: async ({}, provide) => {
|
||||
const repo = await createTempGitRepo("legacy-creation-");
|
||||
await provide(repo);
|
||||
await repo.cleanup();
|
||||
},
|
||||
project: async ({}, provide) => {
|
||||
const project = await seedWorkspace({ repoPrefix: "legacy-app-", port: daemon.port });
|
||||
await provide(project);
|
||||
await project.cleanup();
|
||||
},
|
||||
});
|
||||
test.describe(`published daemon ${version}`, () => {
|
||||
test.beforeAll(async () => {
|
||||
test.setTimeout(120_000);
|
||||
daemon = await startIsolatedHostDaemon(`srv_creation_${randomUUID()}`, {
|
||||
publishedVersion: version,
|
||||
});
|
||||
});
|
||||
test.afterAll(async () => {
|
||||
await daemon?.close();
|
||||
});
|
||||
|
||||
for (const kind of ["workspace", "agent"] as const) {
|
||||
test(`current client creates a keyed ${kind} and initial prompt on a published old daemon`, async ({
|
||||
repo,
|
||||
client,
|
||||
}) => {
|
||||
const prompt = `Create ${kind}: emit 1 coalesced agent stream updates`;
|
||||
const features = client.getLastServerInfoMessage()?.features;
|
||||
expect(features?.creationLifecycle).not.toBe(true);
|
||||
expect(features?.workspaceRequestReceipts).not.toBe(true);
|
||||
expect(features?.agentRequestReceipts === true).toBe(version === "0.8.0");
|
||||
|
||||
const create = await prepareCreation(client, kind, repo.path, prompt);
|
||||
const [agent, duplicate] = await Promise.all([create(), create()]);
|
||||
expect(duplicate.id).toBe(agent.id);
|
||||
expect(agent.title).toBe(prompt);
|
||||
await client.waitForFinish(agent.id, 20_000);
|
||||
const agents = await client.fetchAgents();
|
||||
expect(agents.entries).toHaveLength(1);
|
||||
expect(agents.entries[0]!.agent.title).toBe(prompt);
|
||||
const timeline = await client.fetchAgentTimeline(agent.id);
|
||||
expect(
|
||||
timeline.entries.filter(
|
||||
({ item }) => item.type === "user_message" && item.text === prompt,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
|
||||
test("current app creates a workspace, navigates before agent startup, and creates another agent on an old daemon", async ({
|
||||
page,
|
||||
project,
|
||||
client,
|
||||
gate,
|
||||
}) => {
|
||||
await openOldHost(page, project.workspaceId);
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
const prompt = "Create this workspace: emit 1 coalesced agent stream updates";
|
||||
await fillComposerDraft(page, prompt);
|
||||
await pressSubmitBeforeTheNextRender(page, "Create");
|
||||
await gate.waitForRequest();
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expect(
|
||||
page.getByTestId("user-message").filter({ visible: true }).filter({ hasText: prompt }),
|
||||
).toBeVisible();
|
||||
expect((await client.fetchAgents()).entries).toHaveLength(0);
|
||||
const workspaces = (await client.fetchWorkspaces()).entries.filter(
|
||||
(workspace) =>
|
||||
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
|
||||
);
|
||||
expect(workspaces).toHaveLength(1);
|
||||
gate.release();
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
|
||||
const first = (await client.fetchAgents()).entries[0]!.agent;
|
||||
await client.waitForFinish(first.id, 20_000);
|
||||
await expectPromptOnce(client, first.id, prompt);
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${first.id}`).filter({ visible: true }),
|
||||
).toHaveText(prompt);
|
||||
|
||||
await createAgentTabFromMenu(page);
|
||||
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
|
||||
await fillComposerDraft(page, secondPrompt);
|
||||
await pressSubmitBeforeTheNextRender(page, "Send message");
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
|
||||
const second = (await client.fetchAgents()).entries.find(
|
||||
({ agent }) => agent.id !== first.id,
|
||||
)!.agent;
|
||||
expect(second.workspaceId).toBe(first.workspaceId);
|
||||
await client.waitForFinish(second.id, 20_000);
|
||||
await expectPromptOnce(client, second.id, secondPrompt);
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${second.id}`).filter({ visible: true }),
|
||||
).toHaveText(secondPrompt);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("user-message")
|
||||
.filter({ visible: true })
|
||||
.filter({ hasText: secondPrompt }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath("old-daemon-creation.png") });
|
||||
});
|
||||
|
||||
async function expectPromptOnce(client: DaemonClient, agentId: string, text: string) {
|
||||
const timeline = await client.fetchAgentTimeline(agentId);
|
||||
expect(
|
||||
timeline.entries.filter(({ item }) => item.type === "user_message" && item.text === text),
|
||||
).toHaveLength(1);
|
||||
}
|
||||
|
||||
async function openOldHost(page: Page, workspaceId: string) {
|
||||
const host = buildSeededHost({
|
||||
serverId: daemon.serverId,
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ seededHost, preferences }) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences() },
|
||||
);
|
||||
await page.goto(buildHostWorkspaceRoute(daemon.serverId, workspaceId));
|
||||
}
|
||||
|
||||
async function prepareCreation(
|
||||
client: DaemonClient,
|
||||
kind: "workspace" | "agent",
|
||||
cwd: string,
|
||||
prompt: string,
|
||||
) {
|
||||
const input = { provider: "mock", cwd, initialPrompt: prompt };
|
||||
if (kind === "workspace")
|
||||
return async () => {
|
||||
const result = await client.createWorkspace({
|
||||
idempotencyKey: "workspace-intent",
|
||||
source: { kind: "worktree", cwd, worktreeSlug: "created-once" },
|
||||
agent: input,
|
||||
});
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.workspace?.workspaceDirectory).not.toBe(cwd);
|
||||
return result.agent!;
|
||||
};
|
||||
const workspace = await client.createWorkspace({
|
||||
idempotencyKey: "empty-workspace",
|
||||
source: { kind: "directory", path: cwd },
|
||||
});
|
||||
return () =>
|
||||
client.createAgent({
|
||||
...input,
|
||||
workspaceId: workspace.workspace!.id,
|
||||
idempotencyKey: "agent-intent",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function holdLegacyAgentCreation(page: Page, port: number) {
|
||||
const readers = await loadSessionMessageReaders();
|
||||
const arrived = Promise.withResolvers<void>();
|
||||
let release = () => {};
|
||||
let holding = true;
|
||||
await page.routeWebSocket(new RegExp(`:${port}/ws`), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((frame) => {
|
||||
if (holding && readers.client(frame)?.type === "create_agent_request") {
|
||||
holding = false;
|
||||
release = () => server.send(frame);
|
||||
arrived.resolve();
|
||||
} else server.send(frame);
|
||||
});
|
||||
server.onMessage((frame) => browser.send(frame));
|
||||
});
|
||||
return {
|
||||
waitForRequest: () => arrived.promise,
|
||||
release() {
|
||||
const send = release;
|
||||
release = () => {};
|
||||
send();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -58,7 +58,7 @@ function fixture(modern: boolean) {
|
||||
resolve = done;
|
||||
});
|
||||
const client = new CreationClient({
|
||||
supports: () => modern,
|
||||
supports: (feature) => (feature === "creationLifecycle" ? modern : true),
|
||||
requestId: () => "generated-key",
|
||||
request: async (kind, input) => {
|
||||
requests.push({ kind, input });
|
||||
@@ -73,9 +73,6 @@ function fixture(modern: boolean) {
|
||||
legacy.push({ kind: "agent", input });
|
||||
return agent;
|
||||
},
|
||||
sendMessage: async (id, text, options) => {
|
||||
legacy.push({ kind: "message", input: { id, text, ...options } });
|
||||
},
|
||||
});
|
||||
const input = {
|
||||
idempotencyKey: "intent-one",
|
||||
@@ -155,20 +152,10 @@ test("legacy adaptation keeps workspace, agent and initial prompt sequencing ins
|
||||
input: {
|
||||
config: { provider: "codex", cwd: workspace.workspaceDirectory },
|
||||
workspaceId: workspace.id,
|
||||
idempotencyKey: "intent-one:agent",
|
||||
initialPrompt: "Start once",
|
||||
clientMessageId: "message-one",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "message",
|
||||
input: {
|
||||
id: agent.id,
|
||||
text: "Start once",
|
||||
messageId: "message-one",
|
||||
images: undefined,
|
||||
attachments: undefined,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(f.requests).toEqual([]);
|
||||
f.client.close();
|
||||
@@ -194,7 +181,10 @@ test("keyed creation with an empty prompt works on legacy daemons without sendin
|
||||
initialPrompt: "",
|
||||
});
|
||||
expect(f.legacy).toEqual([
|
||||
{ kind: "agent", input: { idempotencyKey: "empty-agent", config: f.input.agent.config } },
|
||||
{
|
||||
kind: "agent",
|
||||
input: { idempotencyKey: "empty-agent", config: f.input.agent.config, initialPrompt: "" },
|
||||
},
|
||||
]);
|
||||
f.client.close();
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface CreationResult {
|
||||
requestId?: string;
|
||||
}
|
||||
interface Dependencies {
|
||||
supports: () => boolean;
|
||||
supports: (feature: "creationLifecycle" | "agentRequestReceipts") => boolean;
|
||||
requestId: () => string;
|
||||
request: (kind: Kind, input: Record<string, unknown>) => Promise<CreationResult>;
|
||||
observe: (
|
||||
@@ -31,15 +31,6 @@ interface Dependencies {
|
||||
legacyWorkspace: (
|
||||
input: CreateWorkspaceRequestOptions,
|
||||
) => Promise<WorkspaceCreateResponse["payload"]>;
|
||||
sendMessage: (
|
||||
id: string,
|
||||
text: string,
|
||||
options: {
|
||||
messageId: string;
|
||||
images?: CreateAgentRequestOptions["images"];
|
||||
attachments?: CreateAgentRequestOptions["attachments"];
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
interface Operation {
|
||||
kind: Kind;
|
||||
@@ -105,23 +96,15 @@ export class CreationClient {
|
||||
|
||||
private async legacyAgent(input: CreateAgentRequestOptions): Promise<CreationResult> {
|
||||
if (input.agentId) throw new Error("Update the host to use caller-selected creation IDs.");
|
||||
const { idempotencyKey: _key, ...unkeyed } = input;
|
||||
const hasPrompt = Boolean(
|
||||
input.initialPrompt || input.images?.length || input.attachments?.length,
|
||||
);
|
||||
// COMPAT(creationLifecycle): added in v0.8.0, remove after 2027-03-11 once the daemon floor supports creationLifecycle.
|
||||
// Old keyed creates reject initialPrompt. Keep this adaptation private to the SDK.
|
||||
if (input.idempotencyKey) {
|
||||
const { initialPrompt = "", images, attachments, ...creation } = input;
|
||||
const hasPrompt = Boolean(initialPrompt.trim() || images?.length || attachments?.length);
|
||||
if (hasPrompt && input.outputSchema)
|
||||
throw new Error("Update the host to create a keyed agent with structured initial output.");
|
||||
const agent = await this.deps.legacyAgent(creation);
|
||||
if (hasPrompt)
|
||||
await this.deps.sendMessage(agent.id, initialPrompt, {
|
||||
messageId: input.clientMessageId ?? `${input.idempotencyKey}:initial-message`,
|
||||
images,
|
||||
attachments,
|
||||
});
|
||||
return { agent, error: null };
|
||||
}
|
||||
return { agent: await this.deps.legacyAgent(input), error: null };
|
||||
// Preserve the original create-and-prompt RPC: old hosts use its prompt for
|
||||
// titles, creation context and startup. Their keyed create cannot accept it.
|
||||
const request = !hasPrompt && this.deps.supports("agentRequestReceipts") ? input : unkeyed;
|
||||
return { agent: await this.deps.legacyAgent(request), error: null };
|
||||
}
|
||||
|
||||
private start(
|
||||
@@ -165,7 +148,7 @@ export class CreationClient {
|
||||
resolve,
|
||||
reject,
|
||||
settled: false,
|
||||
modern: this.deps.supports(),
|
||||
modern: this.deps.supports("creationLifecycle"),
|
||||
recovering: false,
|
||||
};
|
||||
this.operations.set(identity, operation);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import {
|
||||
DaemonClient,
|
||||
type DaemonClientTrace,
|
||||
type CreateAgentRequestOptions,
|
||||
type DaemonTransport,
|
||||
type Logger,
|
||||
} from "./daemon-client";
|
||||
@@ -291,23 +292,165 @@ test("advertises consumer-provided browser automation capabilities", async () =>
|
||||
});
|
||||
});
|
||||
|
||||
test("retry-safe creation rejects older hosts before sending any request", async () => {
|
||||
const transport = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "receipt-gate",
|
||||
transportFactory: () => transport.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
const connecting = client.connect();
|
||||
transport.triggerOpen();
|
||||
await connecting;
|
||||
await expect(
|
||||
client.createAgent({ provider: "codex", cwd: "/project", idempotencyKey: "creation" }),
|
||||
).rejects.toThrow("Update the host to use retry-safe agent creation.");
|
||||
expect(transport.sent).toEqual([]);
|
||||
});
|
||||
test.each([
|
||||
{ receipts: false, structured: false },
|
||||
{ receipts: true, structured: false },
|
||||
{ receipts: false, structured: true },
|
||||
{ receipts: true, structured: true },
|
||||
])(
|
||||
"legacy creation preserves the original payload with receipts=$receipts, structured=$structured",
|
||||
async ({ receipts, structured }) => {
|
||||
const transport = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "receipt-gate",
|
||||
transportFactory: () => transport.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
const connecting = client.connect();
|
||||
transport.triggerOpen({ features: { agentRequestReceipts: receipts } });
|
||||
await connecting;
|
||||
const input: CreateAgentRequestOptions = {
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: "/project",
|
||||
title: "Explicit title",
|
||||
model: "gpt-5",
|
||||
modeId: "full-access",
|
||||
},
|
||||
workspaceId: "workspace",
|
||||
callerAgentId: "parent",
|
||||
env: { CREATION_CONTEXT: "preserved" },
|
||||
labels: { source: "test" },
|
||||
idempotencyKey: "creation",
|
||||
clientMessageId: "first-message",
|
||||
initialPrompt: "Start this agent",
|
||||
images: [{ data: "aGVsbG8=", mimeType: "image/png" }],
|
||||
attachments: [
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 123,
|
||||
title: "Review this PR",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
},
|
||||
],
|
||||
...(structured ? { outputSchema: { type: "object" } } : {}),
|
||||
};
|
||||
const created = client.createAgent(input);
|
||||
void created.catch(() => {});
|
||||
const duplicate = client.createAgent(input);
|
||||
void duplicate.catch(() => {});
|
||||
expect(transport.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(transport.sent[0]);
|
||||
expect(request).toMatchObject({
|
||||
type: "create_agent_request",
|
||||
config: input.config,
|
||||
workspaceId: input.workspaceId,
|
||||
callerAgentId: input.callerAgentId,
|
||||
env: input.env,
|
||||
labels: input.labels,
|
||||
clientMessageId: input.clientMessageId,
|
||||
initialPrompt: input.initialPrompt,
|
||||
images: input.images,
|
||||
attachments: input.attachments,
|
||||
...(structured ? { outputSchema: input.outputSchema } : {}),
|
||||
});
|
||||
expect(request).not.toHaveProperty("idempotencyKey");
|
||||
transport.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_create_failed",
|
||||
requestId: request.requestId,
|
||||
error: "Provider failed",
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(created).rejects.toThrow("Provider failed");
|
||||
await expect(duplicate).rejects.toThrow("Provider failed");
|
||||
},
|
||||
);
|
||||
|
||||
test.each([false, true])(
|
||||
"legacy workspace creation preserves availability with receipt support=%s",
|
||||
async (receipts) => {
|
||||
const transport = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "legacy-workspace",
|
||||
transportFactory: () => transport.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
const connecting = client.connect();
|
||||
transport.triggerOpen({ features: { workspaceRequestReceipts: receipts } });
|
||||
await connecting;
|
||||
const input = {
|
||||
source: { kind: "directory" as const, path: "/project" },
|
||||
idempotencyKey: "workspace",
|
||||
};
|
||||
const created = client.createWorkspace(input);
|
||||
void created.catch(() => {});
|
||||
const duplicate = client.createWorkspace(input);
|
||||
void duplicate.catch(() => {});
|
||||
expect(transport.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(transport.sent[0]);
|
||||
expect(request).toMatchObject({ type: "workspace.create.request", source: input.source });
|
||||
expect(request.idempotencyKey).toBe(receipts ? input.idempotencyKey : undefined);
|
||||
transport.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "workspace.create.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspace: null,
|
||||
error: "Directory unavailable",
|
||||
setupTerminalId: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(created).resolves.toMatchObject({ error: "Directory unavailable" });
|
||||
await expect(duplicate).resolves.toMatchObject({ error: "Directory unavailable" });
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["agent", "workspace"] as const)(
|
||||
"a lost legacy %s response is not automatically replayed on reconnect",
|
||||
async (kind) => {
|
||||
const transport = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "legacy-disconnect",
|
||||
transportFactory: () => transport.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
const connecting = client.connect();
|
||||
transport.triggerOpen({ features: {} });
|
||||
await connecting;
|
||||
const creation =
|
||||
kind === "agent"
|
||||
? client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: "/project",
|
||||
initialPrompt: "Start once",
|
||||
idempotencyKey: "intent",
|
||||
})
|
||||
: client.createWorkspace({
|
||||
source: { kind: "directory", path: "/project" },
|
||||
idempotencyKey: "intent",
|
||||
});
|
||||
void creation.catch(() => {});
|
||||
expect(transport.sent).toHaveLength(1);
|
||||
transport.triggerClose({ code: 1006, reason: "Connection lost after dispatch" });
|
||||
await expect(creation).rejects.toThrow();
|
||||
const reconnecting = client.connect();
|
||||
transport.triggerOpen({ features: {} });
|
||||
await reconnecting;
|
||||
expect(transport.sent).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
test("Hub management requires daemon support before dispatching requests", async () => {
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -2711,7 +2711,7 @@ export class DaemonClient {
|
||||
// ============================================================================
|
||||
|
||||
private readonly creations = new CreationClient({
|
||||
supports: () => this.lastServerInfoMessage?.features?.creationLifecycle === true,
|
||||
supports: (feature) => this.lastServerInfoMessage?.features?.[feature] === true,
|
||||
requestId: () => this.createRequestId(),
|
||||
request: (kind, input) =>
|
||||
kind === "workspace"
|
||||
@@ -2747,7 +2747,6 @@ export class DaemonClient {
|
||||
},
|
||||
legacyAgent: (input) => this.createLegacyAgent(input),
|
||||
legacyWorkspace: (input) => this.createLegacyWorkspace(input, input.requestId),
|
||||
sendMessage: (id, text, options) => this.sendMessage(id, text, options),
|
||||
});
|
||||
|
||||
async createAgent(options: CreateAgentRequestOptions): Promise<AgentSnapshotPayload> {
|
||||
@@ -2762,7 +2761,6 @@ export class DaemonClient {
|
||||
private async createLegacyAgent(
|
||||
options: CreateAgentRequestOptions,
|
||||
): Promise<AgentSnapshotPayload> {
|
||||
if (options.idempotencyKey !== undefined) this.requireAgentRequestReceipts();
|
||||
const requestId = this.createRequestId(options.requestId);
|
||||
const config = resolveAgentConfig(options);
|
||||
|
||||
@@ -2816,13 +2814,6 @@ export class DaemonClient {
|
||||
return status.agent;
|
||||
}
|
||||
|
||||
private requireAgentRequestReceipts(): void {
|
||||
// COMPAT(agentRequestReceipts): added in v0.7.3; remove gate after 2027-03-05.
|
||||
if (this.lastServerInfoMessage?.features?.agentRequestReceipts !== true) {
|
||||
throw new Error("Update the host to use retry-safe agent creation.");
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAgent(agentId: string): Promise<void> {
|
||||
const requestId = this.createRequestId();
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
@@ -4478,19 +4469,16 @@ export class DaemonClient {
|
||||
input: CreateWorkspaceRequestOptions,
|
||||
requestId?: string,
|
||||
): Promise<WorkspaceCreatePayload> {
|
||||
// COMPAT(workspaceRequestReceipts): added in v0.8.0; remove gate after 2027-03-07.
|
||||
if (
|
||||
input.idempotencyKey !== undefined &&
|
||||
!this.lastServerInfoMessage?.features?.workspaceRequestReceipts
|
||||
) {
|
||||
throw new Error("Update the host to use retry-safe workspace creation.");
|
||||
}
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "workspace.create.request",
|
||||
source: input.source,
|
||||
...(input.idempotencyKey !== undefined ? { idempotencyKey: input.idempotencyKey } : {}),
|
||||
// COMPAT(workspaceRequestReceipts): added in v0.8.0; remove after 2027-03-15 once the daemon floor supports workspace receipts.
|
||||
...(this.lastServerInfoMessage?.features?.workspaceRequestReceipts &&
|
||||
input.idempotencyKey !== undefined
|
||||
? { idempotencyKey: input.idempotencyKey }
|
||||
: {}),
|
||||
...(input.title !== undefined ? { title: input.title } : {}),
|
||||
...(input.firstAgentContext !== undefined
|
||||
? { firstAgentContext: input.firstAgentContext }
|
||||
|
||||
Reference in New Issue
Block a user