mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Thread resumeSessionId through the ACP session spawn pipeline so agents can resume existing sessions (e.g. a prior Codex conversation) instead of starting fresh. Flow: sessions_spawn tool → spawnAcpDirect → initializeSession → ensureSession → acpx --resume-session flag → agent session/load - Add resumeSessionId param to sessions-spawn-tool schema with description so agents can discover and use it - Thread through SpawnAcpParams → AcpInitializeSessionInput → AcpRuntimeEnsureInput → acpx extension runtime - Pass as --resume-session flag to acpx CLI - Error hard (exit 4) on non-existent session, no silent fallback - All new fields optional for backward compatibility Depends on acpx >= 0.1.16 (openclaw/acpx#85, merged, pending release). Tests: 26/26 pass (runtime + tool schema) Verified e2e: Discord → sessions_spawn(resumeSessionId) → Codex resumed session and recalled stored secret. 🤖 AI-assisted
254 lines
7.2 KiB
TypeScript
254 lines
7.2 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const hoisted = vi.hoisted(() => {
|
|
const spawnSubagentDirectMock = vi.fn();
|
|
const spawnAcpDirectMock = vi.fn();
|
|
return {
|
|
spawnSubagentDirectMock,
|
|
spawnAcpDirectMock,
|
|
};
|
|
});
|
|
|
|
vi.mock("../subagent-spawn.js", () => ({
|
|
SUBAGENT_SPAWN_MODES: ["run", "session"],
|
|
spawnSubagentDirect: (...args: unknown[]) => hoisted.spawnSubagentDirectMock(...args),
|
|
}));
|
|
|
|
vi.mock("../acp-spawn.js", () => ({
|
|
ACP_SPAWN_MODES: ["run", "session"],
|
|
ACP_SPAWN_STREAM_TARGETS: ["parent"],
|
|
spawnAcpDirect: (...args: unknown[]) => hoisted.spawnAcpDirectMock(...args),
|
|
}));
|
|
|
|
const { createSessionsSpawnTool } = await import("./sessions-spawn-tool.js");
|
|
|
|
describe("sessions_spawn tool", () => {
|
|
beforeEach(() => {
|
|
hoisted.spawnSubagentDirectMock.mockReset().mockResolvedValue({
|
|
status: "accepted",
|
|
childSessionKey: "agent:main:subagent:1",
|
|
runId: "run-subagent",
|
|
});
|
|
hoisted.spawnAcpDirectMock.mockReset().mockResolvedValue({
|
|
status: "accepted",
|
|
childSessionKey: "agent:codex:acp:1",
|
|
runId: "run-acp",
|
|
});
|
|
});
|
|
|
|
it("uses subagent runtime by default", async () => {
|
|
const tool = createSessionsSpawnTool({
|
|
agentSessionKey: "agent:main:main",
|
|
agentChannel: "discord",
|
|
agentAccountId: "default",
|
|
agentTo: "channel:123",
|
|
agentThreadId: "456",
|
|
});
|
|
|
|
const result = await tool.execute("call-1", {
|
|
task: "build feature",
|
|
agentId: "main",
|
|
model: "anthropic/claude-sonnet-4-6",
|
|
thinking: "medium",
|
|
runTimeoutSeconds: 5,
|
|
thread: true,
|
|
mode: "session",
|
|
cleanup: "keep",
|
|
});
|
|
|
|
expect(result.details).toMatchObject({
|
|
status: "accepted",
|
|
childSessionKey: "agent:main:subagent:1",
|
|
runId: "run-subagent",
|
|
});
|
|
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
task: "build feature",
|
|
agentId: "main",
|
|
model: "anthropic/claude-sonnet-4-6",
|
|
thinking: "medium",
|
|
runTimeoutSeconds: 5,
|
|
thread: true,
|
|
mode: "session",
|
|
cleanup: "keep",
|
|
}),
|
|
expect.objectContaining({
|
|
agentSessionKey: "agent:main:main",
|
|
}),
|
|
);
|
|
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes inherited workspaceDir from tool context, not from tool args", async () => {
|
|
const tool = createSessionsSpawnTool({
|
|
agentSessionKey: "agent:main:main",
|
|
workspaceDir: "/parent/workspace",
|
|
});
|
|
|
|
await tool.execute("call-ws", {
|
|
task: "inspect AGENTS",
|
|
workspaceDir: "/tmp/attempted-override",
|
|
});
|
|
|
|
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith(
|
|
expect.any(Object),
|
|
expect.objectContaining({
|
|
workspaceDir: "/parent/workspace",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("routes to ACP runtime when runtime=acp", async () => {
|
|
const tool = createSessionsSpawnTool({
|
|
agentSessionKey: "agent:main:main",
|
|
agentChannel: "discord",
|
|
agentAccountId: "default",
|
|
agentTo: "channel:123",
|
|
agentThreadId: "456",
|
|
});
|
|
|
|
const result = await tool.execute("call-2", {
|
|
runtime: "acp",
|
|
task: "investigate the failing CI run",
|
|
agentId: "codex",
|
|
cwd: "/workspace",
|
|
thread: true,
|
|
mode: "session",
|
|
streamTo: "parent",
|
|
});
|
|
|
|
expect(result.details).toMatchObject({
|
|
status: "accepted",
|
|
childSessionKey: "agent:codex:acp:1",
|
|
runId: "run-acp",
|
|
});
|
|
expect(hoisted.spawnAcpDirectMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
task: "investigate the failing CI run",
|
|
agentId: "codex",
|
|
cwd: "/workspace",
|
|
thread: true,
|
|
mode: "session",
|
|
streamTo: "parent",
|
|
}),
|
|
expect.objectContaining({
|
|
agentSessionKey: "agent:main:main",
|
|
}),
|
|
);
|
|
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("forwards ACP sandbox options and requester sandbox context", async () => {
|
|
const tool = createSessionsSpawnTool({
|
|
agentSessionKey: "agent:main:subagent:parent",
|
|
sandboxed: true,
|
|
});
|
|
|
|
await tool.execute("call-2b", {
|
|
runtime: "acp",
|
|
task: "investigate",
|
|
agentId: "codex",
|
|
sandbox: "require",
|
|
});
|
|
|
|
expect(hoisted.spawnAcpDirectMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
task: "investigate",
|
|
sandbox: "require",
|
|
}),
|
|
expect.objectContaining({
|
|
agentSessionKey: "agent:main:subagent:parent",
|
|
sandboxed: true,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("passes resumeSessionId through to ACP spawns", async () => {
|
|
const tool = createSessionsSpawnTool({
|
|
agentSessionKey: "agent:main:main",
|
|
});
|
|
|
|
await tool.execute("call-2c", {
|
|
runtime: "acp",
|
|
task: "resume prior work",
|
|
agentId: "codex",
|
|
resumeSessionId: "7f4a78e0-f6be-43fe-855c-c1c4fd229bc4",
|
|
});
|
|
|
|
expect(hoisted.spawnAcpDirectMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
task: "resume prior work",
|
|
agentId: "codex",
|
|
resumeSessionId: "7f4a78e0-f6be-43fe-855c-c1c4fd229bc4",
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it("rejects attachments for ACP runtime", async () => {
|
|
const tool = createSessionsSpawnTool({
|
|
agentSessionKey: "agent:main:main",
|
|
agentChannel: "discord",
|
|
agentAccountId: "default",
|
|
agentTo: "channel:123",
|
|
agentThreadId: "456",
|
|
});
|
|
|
|
const result = await tool.execute("call-3", {
|
|
runtime: "acp",
|
|
task: "analyze file",
|
|
attachments: [{ name: "a.txt", content: "hello", encoding: "utf8" }],
|
|
});
|
|
|
|
expect(result.details).toMatchObject({
|
|
status: "error",
|
|
});
|
|
const details = result.details as { error?: string };
|
|
expect(details.error).toContain("attachments are currently unsupported for runtime=acp");
|
|
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
|
|
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects streamTo when runtime is not "acp"', async () => {
|
|
const tool = createSessionsSpawnTool({
|
|
agentSessionKey: "agent:main:main",
|
|
});
|
|
|
|
const result = await tool.execute("call-3b", {
|
|
runtime: "subagent",
|
|
task: "analyze file",
|
|
streamTo: "parent",
|
|
});
|
|
|
|
expect(result.details).toMatchObject({
|
|
status: "error",
|
|
});
|
|
const details = result.details as { error?: string };
|
|
expect(details.error).toContain("streamTo is only supported for runtime=acp");
|
|
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
|
|
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps attachment content schema unconstrained for llama.cpp grammar safety", () => {
|
|
const tool = createSessionsSpawnTool();
|
|
const schema = tool.parameters as {
|
|
properties?: {
|
|
attachments?: {
|
|
items?: {
|
|
properties?: {
|
|
content?: {
|
|
type?: string;
|
|
maxLength?: number;
|
|
};
|
|
};
|
|
};
|
|
};
|
|
};
|
|
};
|
|
|
|
const contentSchema = schema.properties?.attachments?.items?.properties?.content;
|
|
expect(contentSchema?.type).toBe("string");
|
|
expect(contentSchema?.maxLength).toBeUndefined();
|
|
});
|
|
});
|