refactor: compact copilot sessions through sdk state

Route Copilot compaction through SDK-backed state, remove marker sidecars, preserve auth/session binding behavior in SQLite-backed plugin state, and route Copilot CLI budget compaction through native harness compaction.
This commit is contained in:
Peter Steinberger
2026-06-01 01:18:46 -04:00
committed by GitHub
parent 4550cfa6a7
commit db4990d260
19 changed files with 1353 additions and 533 deletions

View File

@@ -190,11 +190,10 @@ plugins, channels, and core code only see the standard
When `harness.compact` runs, the Copilot SDK harness:
1. Enables `infiniteSessions` on the SDK session.
2. Lets the SDK perform its native compaction.
3. Writes an OpenClaw-shaped marker at
`workspacePath/files/openclaw-compaction-<ts>.json` so existing OpenClaw
transcript readers still see a familiar artifact.
1. Resumes the tracked SDK session without continuing pending work.
2. Calls the SDK's session-scoped history compaction RPC.
3. Returns the SDK compaction outcome without writing compatibility marker
files under the workspace.
The OpenClaw side transcript mirror (see below) continues to receive the
post-compaction messages, so user-facing chat history stays consistent.

View File

@@ -457,6 +457,10 @@ The branch already has a real shared SQLite base:
- GitHub Copilot token exchange cache uses the shared SQLite plugin-state table
under `github-copilot/token-cache/default`. It is provider-owned cache state,
so it intentionally does not add a host schema table.
- GitHub Copilot compaction no longer writes `openclaw-compaction-*.json`
workspace sidecars. The harness calls the SDK history compaction RPC for the
tracked SDK session, and OpenClaw keeps durable session/transcript state in
SQLite instead of compatibility marker files.
- The shared Swift runtime (`OpenClawKit`) uses the same
`state/openclaw.sqlite` rows for device identity and device auth. macOS app
helpers import the shared SQLite helpers instead of owning a second JSON or

View File

@@ -1,16 +1,26 @@
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CopilotClientPool } from "./harness.js";
import { createCopilotAgentHarness, type CopilotSessionBinding } from "./harness.js";
const mocks = vi.hoisted(() => ({
runCopilotAttempt: vi.fn(),
resolvePoolAcquire: vi.fn(
() =>
({
auth: {
agentId: "test",
authMode: "useLoggedInUser",
copilotHome: "/tmp/copilot",
},
key: { agentId: "test", authMode: "useLoggedInUser", copilotHome: "/tmp/copilot" },
options: { copilotHome: "/tmp/copilot", useLoggedInUser: true },
}) as any,
),
createCopilotClientPool: vi.fn(),
}));
vi.mock("./src/attempt.js", () => ({
resolvePoolAcquire: mocks.resolvePoolAcquire,
runCopilotAttempt: mocks.runCopilotAttempt,
}));
@@ -20,6 +30,12 @@ vi.mock("./src/runtime.js", () => ({
const ATTEMPT_PARAMS = { provider: "github-copilot", model: "gpt-4.1" } as any;
const ATTEMPT_RESULT = { ok: true } as any;
const TEST_SESSION_CONFIG = {
availableTools: [],
model: "gpt-4.1",
tools: [],
workingDirectory: "/workspace",
};
function makePoolMock(): CopilotClientPool {
return {
@@ -63,8 +79,18 @@ async function flushAsyncWork() {
describe("createCopilotAgentHarness", () => {
beforeEach(() => {
mocks.runCopilotAttempt.mockReset();
mocks.resolvePoolAcquire.mockClear();
mocks.createCopilotClientPool.mockReset();
mocks.runCopilotAttempt.mockResolvedValue(ATTEMPT_RESULT);
mocks.resolvePoolAcquire.mockReturnValue({
auth: {
agentId: "test",
authMode: "useLoggedInUser",
copilotHome: "/tmp/copilot",
},
key: { agentId: "test", authMode: "useLoggedInUser", copilotHome: "/tmp/copilot" },
options: { copilotHome: "/tmp/copilot", useLoggedInUser: true },
});
mocks.createCopilotClientPool.mockImplementation(() => makePoolMock());
});
@@ -504,7 +530,7 @@ describe("createCopilotAgentHarness", () => {
function makeAttemptParams(overrides: Record<string, unknown> = {}): any {
return {
provider: "github-copilot",
model: { provider: "github-copilot", id: "gpt-4.1" },
model: "gpt-4.1",
cwd: "/ws",
workspaceDir: "/ws",
agentDir: "/home",
@@ -585,6 +611,36 @@ describe("createCopilotAgentHarness", () => {
expect(secondCallParams.initialReplayState?.sdkSessionId).toBeUndefined();
});
it("does not seed when compatibility fingerprint differs (model API change)", async () => {
const pool = makePoolMock();
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-api",
pooledClient: { key: {} as any, client: {} as any },
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(
makeAttemptParams({
runId: "t1",
model: { api: "chat", provider: "github-copilot", id: "gpt-4.1" },
}),
);
await harness.runAttempt(
makeAttemptParams({
runId: "t2",
model: { api: "responses", provider: "github-copilot", id: "gpt-4.1" },
}),
);
const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as {
initialReplayState?: { sdkSessionId?: string };
};
expect(secondCallParams.initialReplayState?.sdkSessionId).toBeUndefined();
});
it("does not seed when compatibility fingerprint differs (legacy auth.gitHubToken rotation)", async () => {
const pool = makePoolMock();
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
@@ -779,7 +835,7 @@ describe("createCopilotAgentHarness", () => {
expect(sessionStore.store.register).toHaveBeenCalledWith(
"oc-sess-reuse",
expect.objectContaining({
schemaVersion: 1,
schemaVersion: 2,
sdkSessionId: "sdk-sess-sqlite",
}),
);
@@ -789,6 +845,45 @@ describe("createCopilotAgentHarness", () => {
expect(secondCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-sqlite");
});
it("resumes shipped schema v1 plugin-state bindings for attempts", async () => {
const sessionStore = makeSessionStoreMock();
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-current",
pooledClient: { key: {} as any, client: {} as any },
});
return ATTEMPT_RESULT;
});
const firstHarness = createCopilotAgentHarness({
pool: makePoolMock(),
sessionStore: sessionStore.store,
});
await firstHarness.runAttempt(makeAttemptParams({ runId: "t1" }));
const stored = sessionStore.entries.get("oc-sess-reuse");
if (!stored) {
throw new Error("expected persisted binding");
}
sessionStore.entries.set("oc-sess-reuse", {
schemaVersion: 1,
sdkSessionId: "sdk-sess-v1",
compatKey: stored.compatKey,
updatedAt: Date.now(),
} as never);
mocks.runCopilotAttempt.mockClear();
const secondHarness = createCopilotAgentHarness({
pool: makePoolMock(),
sessionStore: sessionStore.store,
});
await secondHarness.runAttempt(makeAttemptParams({ runId: "t2" }));
const secondCallParams = mocks.runCopilotAttempt.mock.calls[0]?.[0] as {
initialReplayState?: { sdkSessionId?: string };
};
expect(secondCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-v1");
});
it("starts a fresh SDK session when persisted binding lookup fails", async () => {
const sessionStore = makeSessionStoreMock();
sessionStore.store.lookup.mockImplementation(() => {
@@ -814,9 +909,11 @@ describe("createCopilotAgentHarness", () => {
it("keeps the in-memory binding when durable register fails", async () => {
const sessionStore = makeSessionStoreMock();
sessionStore.entries.set("oc-sess-reuse", {
schemaVersion: 1,
schemaVersion: 2,
sdkSessionId: "sdk-sess-stale",
compatKey: "stale",
compactKey: "stale",
authMode: "useLoggedInUser",
updatedAt: 1,
});
sessionStore.store.register.mockImplementation(() => {
@@ -962,9 +1059,11 @@ describe("createCopilotAgentHarness", () => {
it("deletes persisted sdkSessionId on reset even when no in-memory client is tracked", async () => {
const sessionStore = makeSessionStoreMock();
sessionStore.entries.set("oc-sess-reuse", {
schemaVersion: 1,
schemaVersion: 2,
sdkSessionId: "sdk-sess-orphan",
compatKey: "compat",
compactKey: "compat",
authMode: "useLoggedInUser",
updatedAt: 1,
});
const harness = createCopilotAgentHarness({
@@ -1038,6 +1137,20 @@ describe("createCopilotAgentHarness", () => {
});
describe("compact", () => {
function makeCompactParams(overrides: Record<string, unknown> = {}): any {
return {
provider: "github-copilot",
model: { provider: "github-copilot", id: "gpt-4.1" },
cwd: "/ws",
workspaceDir: "/ws",
agentDir: "/home",
copilotHome: "/copilot-home",
auth: { useLoggedInUser: true },
sessionId: "oc-sess-compact",
...overrides,
};
}
it("returns ok:false when sessionId is missing", async () => {
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
const result = await harness.compact?.({ workspaceDir: "/ws" } as any);
@@ -1048,124 +1161,667 @@ describe("createCopilotAgentHarness", () => {
});
});
it("returns ok:false when workspaceDir is missing", async () => {
it("returns ok:false when the SDK session is not tracked", async () => {
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
const result = await harness.compact?.({ sessionId: "s" } as any);
const result = await harness.compact?.({
sessionId: "oc-sess-compact-1",
trigger: "budget",
currentTokenCount: 12345,
} as any);
expect(result).toEqual({
ok: false,
compacted: false,
reason: "missing-required-params",
reason: "missing_thread_binding",
failure: { reason: "missing_thread_binding" },
});
});
it("writes an OpenClaw marker under <workspaceDir>/files and returns ok:true,compacted:false", async () => {
const workspaceDir = await mkdtemp(join(tmpdir(), "copilot-harness-compact-"));
try {
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
const result = await harness.compact?.({
it("calls the SDK history compaction RPC without requiring a workspace sidecar", async () => {
const compact = vi.fn(async () => ({
success: true,
tokensRemoved: 123,
messagesRemoved: 4,
}));
const disconnect = vi.fn(async () => {
throw new Error("disconnect failed");
});
const resumeSession = vi.fn(async () => ({
disconnect,
rpc: { history: { compact } },
}));
const pool = makePoolMock();
pool.acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
}));
const release = vi.fn(async () => undefined);
pool.release = release;
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-compact",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(
makeCompactParams({
agentId: "main",
sessionId: "oc-sess-compact-1",
workspaceDir,
trigger: "budget",
currentTokenCount: 12345,
} as any);
expect(result).toEqual({
ok: true,
compacted: false,
reason: "deferred-to-sdk-infinite-sessions",
});
const files = await readdir(join(workspaceDir, "files"));
const marker = files.find((f) => f.startsWith("openclaw-compaction-"));
expect(marker).toBeDefined();
expect(marker).toMatch(/openclaw-compaction-\d+-oc-sess-compact-1\.json/);
const contents = JSON.parse(await readFile(join(workspaceDir, "files", marker!), "utf8"));
expect(contents).toMatchObject({
version: 1,
source: "copilot-harness",
sessionId: "oc-sess-compact-1",
compacted: false,
trigger: "budget",
currentTokenCount: 12345,
reason: "deferred-to-sdk-infinite-sessions",
});
} finally {
await rm(workspaceDir, { recursive: true, force: true });
}
});
it("records the tracked sdkSessionId in the marker when an attempt has run", async () => {
const workspaceDir = await mkdtemp(join(tmpdir(), "copilot-harness-compact-tracked-"));
try {
const pool = makePoolMock();
mocks.runCopilotAttempt.mockImplementation(async (params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-tracked",
pooledClient: { key: {} as any, client: { deleteSession: vi.fn() } as any },
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt({ ...ATTEMPT_PARAMS, sessionId: "oc-sess-tracked" });
await harness.compact?.({
sessionId: "oc-sess-tracked",
workspaceDir,
trigger: "manual",
} as any);
const files = await readdir(join(workspaceDir, "files"));
const marker = files.find((f) => f.startsWith("openclaw-compaction-"))!;
const contents = JSON.parse(await readFile(join(workspaceDir, "files", marker), "utf8"));
expect(contents.sdkSessionId).toBe("sdk-sess-tracked");
} finally {
await rm(workspaceDir, { recursive: true, force: true });
}
});
it("records force:true in the marker and surfaces a force-specific reason", async () => {
const workspaceDir = await mkdtemp(join(tmpdir(), "copilot-harness-compact-force-"));
try {
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
const result = await harness.compact?.({
sessionId: "oc-sess-force",
workspaceDir,
force: true,
} as any);
expect(result).toEqual({
ok: true,
compacted: false,
reason: "force-requested-but-sdk-has-no-synchronous-compact-api",
});
const files = await readdir(join(workspaceDir, "files"));
const marker = files.find((f) => f.startsWith("openclaw-compaction-"))!;
const contents = JSON.parse(await readFile(join(workspaceDir, "files", marker), "utf8"));
expect(contents.force).toBe(true);
expect(contents.reason).toBe("force-requested-but-sdk-has-no-synchronous-compact-api");
} finally {
await rm(workspaceDir, { recursive: true, force: true });
}
});
it("returns ok:false with structured failure when the marker write throws", async () => {
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
// Use a path with a NUL character which Node rejects synchronously
// on every platform, simulating a write failure that the harness
// must convert into a structured failure instead of throwing.
const badWorkspace = "/this\u0000is/illegal";
sessionKey: "agent:main:main",
}),
);
const result = await harness.compact?.({
sessionId: "oc-sess-bad",
workspaceDir: badWorkspace,
} as any);
...makeCompactParams({ sessionId: "oc-sess-compact-1" }),
model: "gpt-4.1",
sessionKey: "agent:main:main",
sessionId: "oc-sess-compact-1",
workspaceDir: "/this\u0000is/illegal",
customInstructions: "Keep decisions.",
});
expect(result?.ok).toBe(false);
expect(result?.compacted).toBe(false);
expect(result?.reason).toBe("marker-write-failed");
expect(result?.failure?.reason).toBe("marker-write-failed");
expect(typeof result?.failure?.rawError).toBe("string");
expect(result?.failure?.rawError?.length ?? 0).toBeGreaterThan(0);
expect(resumeSession).toHaveBeenCalledWith(
"sdk-sess-compact",
expect.objectContaining({
availableTools: [],
continuePendingWork: false,
model: "gpt-4.1",
suppressResumeEvent: true,
tools: [],
workingDirectory: "/workspace",
}),
);
expect(compact).toHaveBeenCalledWith({ customInstructions: "Keep decisions." });
expect(disconnect).toHaveBeenCalledTimes(1);
expect(release).toHaveBeenCalledTimes(1);
expect(result).toEqual({
ok: true,
compacted: true,
reason: "copilot-sdk-history-compacted",
});
});
it("disconnects the resumed SDK session when compact aborts after resume", async () => {
const abortController = new AbortController();
const compact = vi.fn(async () => ({
success: true,
tokensRemoved: 123,
messagesRemoved: 4,
}));
const disconnect = vi.fn(async () => undefined);
const resumeSession = vi.fn(async () => {
abortController.abort(new Error("stop compact"));
return {
disconnect,
rpc: { history: { compact } },
};
});
const pool = makePoolMock();
pool.acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
}));
const release = vi.fn(async () => undefined);
pool.release = release;
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-abort",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(
makeCompactParams({
agentId: "main",
sessionId: "oc-sess-abort",
sessionKey: "agent:main:main",
}),
);
const result = await harness.compact?.({
...makeCompactParams({ sessionId: "oc-sess-abort" }),
abortSignal: abortController.signal,
model: "gpt-4.1",
sessionKey: "agent:main:main",
sessionId: "oc-sess-abort",
});
expect(resumeSession).toHaveBeenCalledTimes(1);
expect(compact).not.toHaveBeenCalled();
expect(disconnect).toHaveBeenCalledTimes(1);
expect(release).toHaveBeenCalledTimes(1);
expect(result).toEqual({
ok: false,
compacted: false,
reason: "copilot-sdk-history-compact-failed",
failure: {
reason: "copilot-sdk-history-compact-failed",
rawError: "stop compact",
},
});
});
it("requires matching token auth before compacting a tracked token-auth SDK session", async () => {
const compact = vi.fn(async () => ({
success: true,
tokensRemoved: 45,
messagesRemoved: 2,
}));
const resumeSession = vi.fn(async () => ({
disconnect: vi.fn(async () => undefined),
rpc: { history: { compact } },
}));
const pool = makePoolMock();
const acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
}));
pool.acquire = acquire;
pool.release = vi.fn(async () => undefined);
mocks.resolvePoolAcquire
.mockReturnValueOnce({
auth: {
agentId: "test",
authMode: "gitHubToken",
authProfileId: "p1",
authProfileVersion: "v1",
copilotHome: "/copilot-home",
gitHubToken: "ghp_test",
},
key: { agentId: "test", authMode: "gitHubToken", copilotHome: "/copilot-home" },
options: { copilotHome: "/copilot-home", gitHubToken: "ghp_test" },
})
.mockReturnValueOnce({
auth: {
agentId: "test",
authMode: "useLoggedInUser",
copilotHome: "/copilot-home",
},
key: { agentId: "test", authMode: "useLoggedInUser", copilotHome: "/copilot-home" },
options: { copilotHome: "/copilot-home", useLoggedInUser: true },
})
.mockReturnValueOnce({
auth: {
agentId: "test",
authMode: "gitHubToken",
authProfileId: "p1",
authProfileVersion: "v1",
copilotHome: "/copilot-home",
gitHubToken: "ghp_test",
},
key: { agentId: "test", authMode: "gitHubToken", copilotHome: "/copilot-home" },
options: { copilotHome: "/copilot-home", gitHubToken: "ghp_test" },
});
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-token",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(
makeCompactParams({
auth: { gitHubToken: "ghp_test", profileId: "p1", profileVersion: "v1" },
sessionId: "oc-sess-token",
}),
);
const result = await harness.compact?.(
makeCompactParams({
auth: undefined,
sessionId: "oc-sess-token",
}),
);
expect(acquire).not.toHaveBeenCalled();
expect(resumeSession).not.toHaveBeenCalled();
expect(result).toEqual({
ok: false,
compacted: false,
reason: "missing_thread_binding",
failure: { reason: "missing_thread_binding" },
});
const matchingResult = await harness.compact?.(
makeCompactParams({
auth: undefined,
authProfileId: "p1",
resolvedApiKey: "ghp_test",
sessionId: "oc-sess-token",
}),
);
expect(resumeSession).toHaveBeenCalledWith(
"sdk-sess-token",
expect.objectContaining({
continuePendingWork: false,
gitHubToken: "ghp_test",
model: "gpt-4.1",
suppressResumeEvent: true,
workingDirectory: "/workspace",
}),
);
expect(matchingResult?.compacted).toBe(true);
});
it("does not compact a tracked SDK session after model changes", async () => {
const resumeSession = vi.fn();
const pool = makePoolMock();
const acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
}));
pool.acquire = acquire;
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-model",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(makeCompactParams({ sessionId: "oc-sess-model" }));
const result = await harness.compact?.(
makeCompactParams({ model: "gpt-5", sessionId: "oc-sess-model" }),
);
expect(acquire).not.toHaveBeenCalled();
expect(resumeSession).not.toHaveBeenCalled();
expect(result).toEqual({
ok: false,
compacted: false,
reason: "missing_thread_binding",
failure: { reason: "missing_thread_binding" },
});
});
it("does not compact a logged-in-user SDK session for a token-auth compact request", async () => {
const resumeSession = vi.fn();
const pool = makePoolMock();
pool.acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
}));
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-login",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(makeCompactParams({ sessionId: "oc-sess-login" }));
mocks.resolvePoolAcquire.mockReturnValueOnce({
auth: {
agentId: "test",
authMode: "gitHubToken",
authProfileId: "p1",
authProfileVersion: "v1",
copilotHome: "/copilot-home",
gitHubToken: "ghp_test",
},
key: { agentId: "test", authMode: "gitHubToken", copilotHome: "/copilot-home" },
options: { copilotHome: "/copilot-home", gitHubToken: "ghp_test" },
});
const result = await harness.compact?.(
makeCompactParams({
auth: { gitHubToken: "ghp_test", profileId: "p1", profileVersion: "v1" },
sessionId: "oc-sess-login",
}),
);
expect(resumeSession).not.toHaveBeenCalled();
expect(result).toEqual({
ok: false,
compacted: false,
reason: "missing_thread_binding",
failure: { reason: "missing_thread_binding" },
});
});
it("classifies missing SDK sessions as stale bindings for host recovery", async () => {
const sessionStore = makeSessionStoreMock();
const resumeSession = vi.fn(async () => {
throw new Error("session not found");
});
const pool = makePoolMock();
pool.acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
}));
pool.release = vi.fn(async () => undefined);
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-stale",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool, sessionStore: sessionStore.store });
await harness.runAttempt(makeCompactParams({ sessionId: "oc-sess-stale" }));
const result = await harness.compact?.(makeCompactParams({ sessionId: "oc-sess-stale" }));
expect(sessionStore.store.delete).toHaveBeenCalledWith("oc-sess-stale");
expect(result).toEqual({
ok: false,
compacted: false,
reason: "stale_thread_binding",
failure: { reason: "stale_thread_binding", rawError: "session not found" },
});
});
it("does not start SDK compaction when the compact call is already aborted", async () => {
const abort = new AbortController();
abort.abort(new Error("caller canceled"));
const resumeSession = vi.fn();
const pool = makePoolMock();
pool.acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
}));
pool.release = vi.fn(async () => undefined);
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-abort",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(makeCompactParams({ sessionId: "oc-sess-abort" }));
const result = await harness.compact?.(
makeCompactParams({ abortSignal: abort.signal, sessionId: "oc-sess-abort" }),
);
expect(resumeSession).not.toHaveBeenCalled();
expect(result).toEqual({
ok: false,
compacted: false,
reason: "copilot-sdk-history-compact-failed",
failure: {
reason: "copilot-sdk-history-compact-failed",
rawError: "caller canceled",
},
});
});
it("aborts the SDK manual history compaction when the compact call is canceled", async () => {
const abort = new AbortController();
let rejectCompact: ((reason?: unknown) => void) | undefined;
const compact = vi.fn(
() =>
new Promise<never>((_resolve, reject) => {
rejectCompact = reject;
}),
);
const abortManualCompaction = vi.fn(async () => {
rejectCompact?.(new Error("manual compaction aborted"));
return { aborted: true };
});
const disconnect = vi.fn(async () => undefined);
const resumeSession = vi.fn(async () => ({
disconnect,
rpc: { history: { abortManualCompaction, compact } },
}));
const pool = makePoolMock();
pool.acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
}));
pool.release = vi.fn(async () => undefined);
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-cancel",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(makeCompactParams({ sessionId: "oc-sess-cancel" }));
const resultPromise = harness.compact?.(
makeCompactParams({ abortSignal: abort.signal, sessionId: "oc-sess-cancel" }),
);
await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(1));
abort.abort(new Error("caller canceled"));
const result = await resultPromise;
expect(abortManualCompaction).toHaveBeenCalledTimes(1);
expect(result).toEqual({
ok: false,
compacted: false,
reason: "copilot-sdk-history-compact-failed",
failure: {
reason: "copilot-sdk-history-compact-failed",
rawError: "caller canceled",
},
});
});
it("refuses persisted token-auth bindings without matching token auth", async () => {
const sessionStore = makeSessionStoreMock();
mocks.resolvePoolAcquire.mockReturnValueOnce({
auth: {
agentId: "test",
authMode: "gitHubToken",
authProfileId: "p1",
authProfileVersion: "v1",
copilotHome: "/copilot-home",
gitHubToken: "ghp_test",
},
key: { agentId: "test", authMode: "gitHubToken", copilotHome: "/copilot-home" },
options: { copilotHome: "/copilot-home", gitHubToken: "ghp_test" },
});
mocks.runCopilotAttempt.mockImplementationOnce(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-persisted-token",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession: vi.fn() } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
});
const firstHarness = createCopilotAgentHarness({
pool: makePoolMock(),
sessionStore: sessionStore.store,
});
await firstHarness.runAttempt(
makeCompactParams({
auth: { gitHubToken: "ghp_test", profileId: "p1", profileVersion: "v1" },
sessionId: "oc-sess-persisted-token",
}),
);
const resumeSession = vi.fn();
const secondPool = makePoolMock();
const secondAcquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
}));
secondPool.acquire = secondAcquire;
const secondHarness = createCopilotAgentHarness({
pool: secondPool,
sessionStore: sessionStore.store,
});
const result = await secondHarness.compact?.(
makeCompactParams({ auth: undefined, sessionId: "oc-sess-persisted-token" }),
);
expect(secondAcquire).not.toHaveBeenCalled();
expect(resumeSession).not.toHaveBeenCalled();
expect(result).toEqual({
ok: false,
compacted: false,
reason: "missing_thread_binding",
failure: { reason: "missing_thread_binding" },
});
mocks.resolvePoolAcquire.mockReturnValueOnce({
auth: {
agentId: "test",
authMode: "gitHubToken",
authProfileId: "p1",
authProfileVersion: "v2",
copilotHome: "/copilot-home",
gitHubToken: "ghp_other",
},
key: { agentId: "test", authMode: "gitHubToken", copilotHome: "/copilot-home" },
options: { copilotHome: "/copilot-home", gitHubToken: "ghp_other" },
});
const rotatedPool = makePoolMock();
const rotatedAcquire = vi.fn();
rotatedPool.acquire = rotatedAcquire;
const rotatedHarness = createCopilotAgentHarness({
pool: rotatedPool,
sessionStore: sessionStore.store,
});
const rotatedResult = await rotatedHarness.compact?.(
makeCompactParams({
auth: { gitHubToken: "ghp_other", profileId: "p1", profileVersion: "v2" },
sessionId: "oc-sess-persisted-token",
}),
);
expect(rotatedAcquire).not.toHaveBeenCalled();
expect(rotatedResult).toEqual({
ok: false,
compacted: false,
reason: "missing_thread_binding",
failure: { reason: "missing_thread_binding" },
});
});
it("does not compact a persisted SDK binding after harness restart", async () => {
const sessionStore = makeSessionStoreMock();
const firstHarness = createCopilotAgentHarness({
pool: makePoolMock(),
sessionStore: sessionStore.store,
});
mocks.runCopilotAttempt.mockImplementationOnce(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-persisted",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession: vi.fn() } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
});
await firstHarness.runAttempt(makeCompactParams({ sessionId: "oc-sess-persisted" }));
const resumeSession = vi.fn();
const secondPool = makePoolMock();
const secondAcquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
}));
secondPool.acquire = secondAcquire;
secondPool.release = vi.fn(async () => undefined);
const secondHarness = createCopilotAgentHarness({
pool: secondPool,
sessionStore: sessionStore.store,
});
const result = await secondHarness.compact?.(
makeCompactParams({ sessionId: "oc-sess-persisted" }),
);
expect(secondAcquire).not.toHaveBeenCalled();
expect(resumeSession).not.toHaveBeenCalled();
expect(sessionStore.store.delete).not.toHaveBeenCalledWith("oc-sess-persisted");
expect(result).toEqual({
ok: false,
compacted: false,
reason: "missing_thread_binding",
failure: { reason: "missing_thread_binding" },
});
});
it("reports SDK history compaction no-ops without writing compatibility state", async () => {
const compact = vi.fn(async () => ({
success: true,
tokensRemoved: 0,
messagesRemoved: 0,
}));
const resumeSession = vi.fn(async () => ({
disconnect: vi.fn(async () => undefined),
rpc: { history: { compact } },
}));
const pool = makePoolMock();
pool.acquire = vi.fn(async () => ({
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
}));
pool.release = vi.fn(async () => undefined);
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-noop",
pooledClient: {
key: {} as any,
client: { deleteSession: vi.fn(), resumeSession } as any,
},
sessionConfig: TEST_SESSION_CONFIG,
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(makeCompactParams({ sessionId: "oc-sess-noop" }));
const result = await harness.compact?.({
...makeCompactParams({ sessionId: "oc-sess-noop" }),
sessionId: "oc-sess-noop",
workspaceDir: "/this\u0000is/illegal",
});
expect(compact).toHaveBeenCalledWith(undefined);
expect(result).toEqual({
ok: true,
compacted: false,
reason: "already under target",
});
});
});

View File

@@ -1,16 +1,24 @@
import type { CopilotClient } from "@github/copilot-sdk";
import type {
AgentHarness,
AgentHarnessAttemptParams,
AgentHarnessAttemptResult,
AgentHarnessCompactParams,
AgentHarnessCompactResult,
AgentHarnessResetParams,
import {
compactWithSafetyTimeout,
resolveCompactionTimeoutMs,
type AgentHarness,
type AgentHarnessAttemptParams,
type AgentHarnessAttemptResult,
type AgentHarnessCompactParams,
type AgentHarnessCompactResult,
type AgentHarnessResetParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import type { CopilotSessionConfig } from "./src/attempt.js";
import { resolveCopilotAuth } from "./src/auth-bridge.js";
import { writeOpenClawCompactionMarker } from "./src/compaction-bridge.js";
import type { CopilotClientPool, CopilotClientPoolOptions, PooledClient } from "./src/runtime.js";
import type {
ClientCreateOptions,
CopilotClientPool,
CopilotClientPoolOptions,
PooledClient,
PoolKey,
} from "./src/runtime.js";
export type { CopilotClientPool, CopilotClientPoolOptions };
@@ -28,6 +36,9 @@ export interface CreateCopilotAgentHarnessOptions {
interface TrackedSession {
sdkSessionId: string;
client: CopilotClient;
clientOptions: ClientCreateOptions;
poolKey: PoolKey;
sessionConfig: CopilotSessionConfig;
// Compatibility fingerprint of the params that created the SDK
// session. We only reuse the tracked SDK session when the next
// attempt's fingerprint matches — different provider/model/cwd/auth
@@ -36,49 +47,153 @@ interface TrackedSession {
// `createSession` (no resume injection) and the new sdkSessionId
// replaces this entry via `onSessionEstablished`.
compatKey: string;
compactKey: string;
authMode: "gitHubToken" | "useLoggedInUser";
authProfileId?: string;
authProfileVersion?: string;
}
interface CopilotHistoryCompactResult {
success: boolean;
tokensRemoved: number;
messagesRemoved: number;
summaryContent?: string;
}
interface CopilotHistoryCompactSession {
abort(): Promise<void>;
disconnect(): Promise<void>;
rpc: {
history: {
abortManualCompaction(): Promise<{ aborted: boolean }>;
compact(params?: { customInstructions?: string }): Promise<CopilotHistoryCompactResult>;
};
};
}
export type CopilotSessionBinding = {
schemaVersion: 2;
sdkSessionId: string;
compatKey: string;
compactKey: string;
authMode: "gitHubToken" | "useLoggedInUser";
authProfileId?: string;
authProfileVersion?: string;
updatedAt: number;
};
type LegacyCopilotSessionBinding = {
schemaVersion: 1;
sdkSessionId: string;
compatKey: string;
updatedAt: number;
};
type CopilotAttemptSessionBinding = Pick<CopilotSessionBinding, "compatKey" | "sdkSessionId">;
type CopilotSessionBindingStore = Pick<
PluginStateSyncKeyedStore<CopilotSessionBinding>,
"delete" | "lookup" | "register"
>;
type CopilotSessionAuth = Pick<
CopilotSessionBinding,
"authMode" | "authProfileId" | "authProfileVersion"
>;
function sessionAuthFields(auth: CopilotSessionAuth): CopilotSessionAuth {
return auth.authMode === "gitHubToken"
? {
authMode: "gitHubToken",
authProfileId: auth.authProfileId,
authProfileVersion: auth.authProfileVersion,
}
: { authMode: "useLoggedInUser" };
}
function sessionAuthMatches(stored: CopilotSessionAuth, current: CopilotSessionAuth): boolean {
if (stored.authMode !== current.authMode) {
return false;
}
if (stored.authMode === "useLoggedInUser") {
return true;
}
return (
current.authMode === "gitHubToken" &&
stored.authProfileId === current.authProfileId &&
stored.authProfileVersion === current.authProfileVersion
);
}
function normalizeBinding(
value: CopilotSessionBinding | undefined,
): CopilotSessionBinding | undefined {
if (
!value ||
value.schemaVersion !== 1 ||
value.schemaVersion !== 2 ||
typeof value.sdkSessionId !== "string" ||
value.sdkSessionId.trim() === "" ||
typeof value.compatKey !== "string" ||
value.compatKey.trim() === "" ||
typeof value.compactKey !== "string" ||
value.compactKey.trim() === "" ||
(value.authMode !== "gitHubToken" && value.authMode !== "useLoggedInUser") ||
(value.authMode === "gitHubToken" &&
(typeof value.authProfileId !== "string" ||
value.authProfileId.trim() === "" ||
typeof value.authProfileVersion !== "string" ||
value.authProfileVersion.trim() === "")) ||
typeof value.updatedAt !== "number" ||
!Number.isFinite(value.updatedAt)
) {
return undefined;
}
return {
schemaVersion: 1,
schemaVersion: 2,
sdkSessionId: value.sdkSessionId.trim(),
compatKey: value.compatKey,
compactKey: value.compactKey,
authMode: value.authMode,
...(value.authMode === "gitHubToken"
? {
authProfileId: value.authProfileId,
authProfileVersion: value.authProfileVersion,
}
: {}),
updatedAt: value.updatedAt,
};
}
function normalizeAttemptBinding(value: unknown): CopilotAttemptSessionBinding | undefined {
const current = normalizeBinding(value as CopilotSessionBinding | undefined);
if (current) {
return current;
}
const legacy = value as LegacyCopilotSessionBinding | undefined;
if (
!legacy ||
legacy.schemaVersion !== 1 ||
typeof legacy.sdkSessionId !== "string" ||
legacy.sdkSessionId.trim() === "" ||
typeof legacy.compatKey !== "string" ||
legacy.compatKey.trim() === "" ||
typeof legacy.updatedAt !== "number" ||
!Number.isFinite(legacy.updatedAt)
) {
return undefined;
}
return {
sdkSessionId: legacy.sdkSessionId.trim(),
compatKey: legacy.compatKey,
};
}
function lookupStoredBinding(
store: CopilotSessionBindingStore | undefined,
key: string,
): CopilotSessionBinding | undefined {
): CopilotAttemptSessionBinding | undefined {
try {
return normalizeBinding(store?.lookup(key));
return normalizeAttemptBinding(store?.lookup(key));
} catch {
try {
store?.delete(key);
@@ -118,6 +233,58 @@ function deleteStoredBinding(store: CopilotSessionBindingStore | undefined, key:
}
}
function throwIfAborted(signal: AbortSignal | undefined): void {
if (!signal?.aborted) {
return;
}
const reason = "reason" in signal ? signal.reason : undefined;
if (reason instanceof Error) {
throw reason;
}
const error = reason ? new Error("aborted", { cause: reason }) : new Error("aborted");
error.name = "AbortError";
throw error;
}
function isStaleSdkSessionError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /\b(404|not found|no such session|unknown session|stale|deleted|does not exist)\b/i.test(
message,
);
}
async function compactTrackedSdkSession(params: {
abortSignal?: AbortSignal;
client: CopilotClient;
customInstructions?: string;
gitHubToken?: string;
onSession?: (session: CopilotHistoryCompactSession) => void;
sessionConfig: CopilotSessionConfig;
sdkSessionId: string;
}): Promise<CopilotHistoryCompactResult> {
throwIfAborted(params.abortSignal);
const session = (await params.client.resumeSession(params.sdkSessionId, {
...params.sessionConfig,
continuePendingWork: false,
...(params.gitHubToken ? { gitHubToken: params.gitHubToken } : {}),
suppressResumeEvent: true,
})) as unknown as CopilotHistoryCompactSession;
params.onSession?.(session);
const request = params.customInstructions?.trim()
? { customInstructions: params.customInstructions }
: undefined;
try {
throwIfAborted(params.abortSignal);
return await session.rpc.history.compact(request);
} finally {
try {
await session.disconnect();
} catch {
// Preserve the compaction or cancellation outcome; cleanup is best-effort here.
}
}
}
// Build a string fingerprint of the attempt params that must agree
// across turns for SDK-session reuse to be safe. Keep this list
// conservative: any field whose change would invalidate the SDK
@@ -135,8 +302,21 @@ function deleteStoredBinding(store: CopilotSessionBindingStore | undefined, key:
// the token (see `tokenFingerprint` in `src/auth-bridge.ts`), so
// rotating the token under the same profile id still invalidates
// the compat key without ever serializing the raw credential.
function computeSessionCompatKey(params: AgentHarnessAttemptParams): string {
const p = params as AgentHarnessAttemptParams & {
type CopilotSessionCompatParams = AgentHarnessAttemptParams | AgentHarnessCompactParams;
function readAgentIdFromSessionKey(sessionKey: unknown): string | undefined {
if (typeof sessionKey !== "string") {
return undefined;
}
const parts = sessionKey.trim().split(":");
return parts[0] === "agent" && parts[1]?.trim() ? parts[1].trim() : undefined;
}
function computeSessionKey(
params: CopilotSessionCompatParams,
options: { includeApi: boolean; includeAuth: boolean },
): string {
const p = params as CopilotSessionCompatParams & {
auth?: {
gitHubToken?: string;
profileId?: string;
@@ -144,18 +324,26 @@ function computeSessionCompatKey(params: AgentHarnessAttemptParams): string {
useLoggedInUser?: boolean;
};
agentId?: string;
agentDir?: string;
authProfileId?: string;
copilotHome?: string;
cwd?: string;
modelId?: string;
model?: string | { api?: string; id?: string; provider?: string };
profileVersion?: string;
resolvedApiKey?: string;
sessionKey?: string;
workspaceDir?: string;
};
const modelObj: { api?: string; id?: string; provider?: string } =
p.model && typeof p.model === "object"
? p.model
: { id: typeof p.model === "string" ? p.model : undefined };
const provider = modelObj.provider ?? (typeof p.provider === "string" ? p.provider : "");
const modelId =
modelObj.id ??
(typeof p.modelId === "string" ? p.modelId : undefined) ??
(typeof p.model === "string" ? p.model : "");
// resolveCopilotAuth can throw when an explicit `auth.gitHubToken`
// is supplied without profileId + profileVersion (the existing
// pool-key safety invariant). That same error would surface
@@ -169,7 +357,7 @@ function computeSessionCompatKey(params: AgentHarnessAttemptParams): string {
let resolvedCopilotHome = "";
try {
const resolved = resolveCopilotAuth({
agentId: typeof p.agentId === "string" ? p.agentId : undefined,
agentId: typeof p.agentId === "string" ? p.agentId : readAgentIdFromSessionKey(p.sessionKey),
agentDir: typeof p.agentDir === "string" ? p.agentDir : undefined,
workspaceDir: typeof p.workspaceDir === "string" ? p.workspaceDir : undefined,
copilotHome: typeof p.copilotHome === "string" ? p.copilotHome : undefined,
@@ -189,19 +377,27 @@ function computeSessionCompatKey(params: AgentHarnessAttemptParams): string {
authParts = ["auth=unresolvable"];
}
const parts = [
`provider=${modelObj.provider ?? ""}`,
`model=${modelObj.id ?? ""}`,
`api=${modelObj.api ?? ""}`,
`provider=${provider}`,
`model=${modelId}`,
...(options.includeApi ? [`api=${modelObj.api ?? ""}`] : []),
`cwd=${p.cwd ?? p.workspaceDir ?? ""}`,
`agentId=${resolvedAgentId}`,
`agentDir=${p.agentDir ?? ""}`,
`copilotHome=${p.copilotHome ?? ""}`,
`resolvedCopilotHome=${resolvedCopilotHome}`,
...authParts,
...(options.includeAuth ? authParts : []),
];
return parts.join("|");
}
function computeSessionCompatKey(params: CopilotSessionCompatParams): string {
return computeSessionKey(params, { includeApi: true, includeAuth: true });
}
function computeSessionCompactKey(params: CopilotSessionCompatParams): string {
return computeSessionKey(params, { includeApi: false, includeAuth: false });
}
export function createCopilotAgentHarness(
options?: CreateCopilotAgentHarnessOptions,
): AgentHarness {
@@ -257,10 +453,11 @@ export function createCopilotAgentHarness(
if (disposed) {
throw new Error("[copilot] harness has been disposed; cannot start new attempts");
}
const { runCopilotAttempt } = await import("./src/attempt.js");
const { resolvePoolAcquire, runCopilotAttempt } = await import("./src/attempt.js");
if (disposed) {
throw new Error("[copilot] harness was disposed while starting an attempt");
}
const poolAcquire = resolvePoolAcquire(params as never);
const pool = await getPool();
if (disposed) {
throw new Error("[copilot] harness was disposed while starting an attempt");
@@ -289,6 +486,7 @@ export function createCopilotAgentHarness(
// back to `createSession`, so a stale-session error never
// surfaces as a prompt error.
const currentCompatKey = computeSessionCompatKey(params);
const currentCompactKey = computeSessionCompactKey(params);
const tracked = openclawSessionId ? trackedSessions.get(openclawSessionId) : undefined;
const stored = openclawSessionId
? resetBlockedStoredSessions.has(openclawSessionId)
@@ -317,19 +515,28 @@ export function createCopilotAgentHarness(
? ({
sdkSessionId,
pooledClient,
sessionConfig,
}: {
sdkSessionId: string;
pooledClient: PooledClient;
sessionConfig: CopilotSessionConfig;
}) => {
trackedSessions.set(openclawSessionId, {
sdkSessionId,
client: pooledClient.client,
clientOptions: poolAcquire.options,
compatKey: currentCompatKey,
compactKey: currentCompactKey,
poolKey: pooledClient.key,
sessionConfig,
...sessionAuthFields(poolAcquire.auth),
});
const persisted = registerStoredBinding(options?.sessionStore, openclawSessionId, {
schemaVersion: 1,
schemaVersion: 2,
sdkSessionId,
compatKey: currentCompatKey,
compactKey: currentCompactKey,
...sessionAuthFields(poolAcquire.auth),
updatedAt: Date.now(),
});
if (persisted) {
@@ -376,20 +583,12 @@ export function createCopilotAgentHarness(
async compact(
params: AgentHarnessCompactParams,
): Promise<AgentHarnessCompactResult | undefined> {
// The GitHub Copilot agent runtime manages compaction automatically via
// `SessionConfig.infiniteSessions` (background-async when
// utilization crosses `backgroundCompactionThreshold`). There is
// no synchronous compact RPC, so the harness cannot honour
// `params.force === true` directly. Instead this method writes
// an OpenClaw-shaped marker file under
// `<workspaceDir>/files/openclaw-compaction-<ts>-<sessionId>.json`
// so existing OpenClaw transcript readers see a familiar
// compaction artifact when the host calls compact(). See
// src/compaction-bridge.ts for the bridge boundary.
// The SDK owns Copilot history compaction. OpenClaw only resumes
// the tracked SDK session and calls the session-scoped RPC; durable
// OpenClaw session/transcript state stays in SQLite, with no marker
// sidecars under the workspace.
const openclawSessionId = typeof params.sessionId === "string" ? params.sessionId : undefined;
const workspaceDir =
typeof params.workspaceDir === "string" ? params.workspaceDir : undefined;
if (!openclawSessionId || !workspaceDir) {
if (!openclawSessionId) {
return {
ok: false,
compacted: false,
@@ -397,34 +596,106 @@ export function createCopilotAgentHarness(
};
}
const tracked = trackedSessions.get(openclawSessionId);
const reason = params.force
? "force-requested-but-sdk-has-no-synchronous-compact-api"
: "deferred-to-sdk-infinite-sessions";
try {
await writeOpenClawCompactionMarker({
sessionId: openclawSessionId,
workspaceDir,
trigger: params.trigger,
currentTokenCount: params.currentTokenCount,
sdkSessionId: tracked?.sdkSessionId,
force: params.force,
reason,
});
} catch (err) {
const currentCompactKey = computeSessionCompactKey(params);
const { resolvePoolAcquire } = await import("./src/attempt.js");
const resolvedPoolAcquire = resolvePoolAcquire(params as never);
const currentAuth = sessionAuthFields(resolvedPoolAcquire.auth);
const compatibleTracked =
tracked?.compactKey === currentCompactKey && sessionAuthMatches(tracked, currentAuth)
? tracked
: undefined;
if (!compatibleTracked) {
// Durable bindings only carry SDK session ids. Manual SDK compaction also
// needs the live SessionConfig with OpenClaw hooks/tools, so preserve the
// binding for the next attempt and let the host compact transcript state.
return {
ok: false,
compacted: false,
reason: "marker-write-failed",
failure: {
reason: "marker-write-failed",
rawError: err instanceof Error ? err.message : String(err),
},
reason: "missing_thread_binding",
failure: { reason: "missing_thread_binding" },
};
}
const poolAcquire = compatibleTracked
? { key: compatibleTracked.poolKey, options: compatibleTracked.clientOptions }
: resolvedPoolAcquire;
let compactResult: CopilotHistoryCompactResult;
let handle: PooledClient | undefined;
let pool: CopilotClientPool | undefined;
let activeSdkSession: CopilotHistoryCompactSession | undefined;
try {
throwIfAborted(params.abortSignal);
pool = await getPool();
handle = await pool.acquire(poolAcquire.key, poolAcquire.options);
const client = handle.client;
compactResult = await compactWithSafetyTimeout(
(abortSignal) =>
compactTrackedSdkSession({
abortSignal,
client,
customInstructions: params.customInstructions,
gitHubToken:
compatibleTracked?.clientOptions.gitHubToken ??
(resolvedPoolAcquire.auth.authMode === "gitHubToken"
? resolvedPoolAcquire.auth.gitHubToken
: undefined),
onSession: (session) => {
activeSdkSession = session;
},
sessionConfig: compatibleTracked.sessionConfig,
sdkSessionId: compatibleTracked.sdkSessionId,
}),
resolveCompactionTimeoutMs(
(params as { config?: Parameters<typeof resolveCompactionTimeoutMs>[0] }).config,
),
{
abortSignal: params.abortSignal,
onCancel: () =>
void activeSdkSession?.rpc.history.abortManualCompaction().catch(() => undefined),
},
);
} catch (err) {
const rawError = err instanceof Error ? err.message : String(err);
if (isStaleSdkSessionError(err)) {
trackedSessions.delete(openclawSessionId);
deleteStoredBinding(options?.sessionStore, openclawSessionId);
return {
ok: false,
compacted: false,
reason: "stale_thread_binding",
failure: { reason: "stale_thread_binding", rawError },
};
}
return {
ok: false,
compacted: false,
reason: "copilot-sdk-history-compact-failed",
failure: {
reason: "copilot-sdk-history-compact-failed",
rawError,
},
};
} finally {
if (pool && handle) {
try {
await pool.release(handle);
} catch {
// Pool release failure must not mask the compaction outcome.
}
}
}
if (!compactResult.success) {
return {
ok: false,
compacted: false,
reason: "copilot-sdk-history-compact-failed",
failure: { reason: "copilot-sdk-history-compact-failed" },
};
}
const compacted = compactResult.tokensRemoved > 0 || compactResult.messagesRemoved > 0;
return {
ok: true,
compacted: false,
reason,
compacted,
reason: compacted ? "copilot-sdk-history-compacted" : "already under target",
};
},

View File

@@ -50,6 +50,21 @@ const SUPPORTED_PROVIDERS = new Set(["github-copilot"]);
type AttemptResultWithSdkSessionId = AgentHarnessAttemptResult & { sdkSessionId?: string };
type PromptErrorWithCode = Error & { code?: string; cause?: unknown };
export type CopilotSessionConfig = Pick<
SessionConfig,
| "availableTools"
| "enableSessionTelemetry"
| "gitHubToken"
| "hooks"
| "instructionDirectories"
| "infiniteSessions"
| "model"
| "onPermissionRequest"
| "reasoningEffort"
| "systemMessage"
| "tools"
| "workingDirectory"
>;
// NOTE(plugin-sdk-widening): AttemptParamsLike can be removed once
// openclaw/plugin-sdk/agent-harness-runtime declares auth, messages,
// onAssistantDelta, and initialReplayState.sdkSessionId fields. Tracked by
@@ -107,7 +122,11 @@ export interface CopilotAttemptDeps {
* thrown from this callback are swallowed so they cannot break the
* attempt.
*/
onSessionEstablished?: (info: { sdkSessionId: string; pooledClient: PooledClient }) => void;
onSessionEstablished?: (info: {
sdkSessionId: string;
pooledClient: PooledClient;
sessionConfig: CopilotSessionConfig;
}) => void;
}
export async function runCopilotAttempt(
@@ -415,7 +434,7 @@ export async function runCopilotAttempt(
sessionIdUsed = sdkSessionId ?? input.sessionId;
if (sdkSessionId && deps.onSessionEstablished) {
try {
deps.onSessionEstablished({ sdkSessionId, pooledClient: handle });
deps.onSessionEstablished({ sdkSessionId, pooledClient: handle, sessionConfig });
} catch {
// never let session-tracking callbacks break attempts
}
@@ -714,21 +733,7 @@ function createSessionConfig(
workspaceBootstrapInstructions: string | undefined,
effectiveWorkspaceDir: string | undefined,
effectiveCwd: string | undefined,
): Pick<
SessionConfig,
| "availableTools"
| "enableSessionTelemetry"
| "gitHubToken"
| "hooks"
| "instructionDirectories"
| "infiniteSessions"
| "model"
| "onPermissionRequest"
| "reasoningEffort"
| "systemMessage"
| "tools"
| "workingDirectory"
> {
): CopilotSessionConfig {
const permissionPolicy = params.permissionPolicy ?? rejectAllPolicy;
const hooks = createHooksBridge(params.hooksConfig);
const infiniteSessions = createInfiniteSessionConfig(params.infiniteSessionConfig);

View File

@@ -1,8 +1,5 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createInfiniteSessionConfig, writeOpenClawCompactionMarker } from "./compaction-bridge.js";
import { describe, expect, it } from "vitest";
import { createInfiniteSessionConfig } from "./compaction-bridge.js";
describe("createInfiniteSessionConfig", () => {
it("returns undefined when no options provided", () => {
@@ -59,184 +56,3 @@ describe("createInfiniteSessionConfig", () => {
expect(result).not.toHaveProperty("bufferExhaustionThreshold");
});
});
describe("writeOpenClawCompactionMarker", () => {
it("writes a JSON marker with expected shape under <workspaceDir>/files", async () => {
const workspaceDir = await mkdtemp(join(tmpdir(), "copilot-compaction-"));
try {
const written = await writeOpenClawCompactionMarker(
{
sessionId: "openclaw-sess-123",
workspaceDir,
trigger: "manual",
currentTokenCount: 42,
sdkSessionId: "sdk-sess-abc",
reason: "deferred-to-sdk-infinite-sessions",
},
{ now: () => 1_700_000_000_000 },
);
expect(written.path).toBe(
join(workspaceDir, "files", "openclaw-compaction-1700000000000-openclaw-sess-123.json"),
);
expect(written.marker).toEqual({
version: 1,
source: "copilot-harness",
sessionId: "openclaw-sess-123",
ts: 1_700_000_000_000,
compacted: false,
trigger: "manual",
sdkSessionId: "sdk-sess-abc",
currentTokenCount: 42,
reason: "deferred-to-sdk-infinite-sessions",
});
const contents = await readFile(written.path, "utf8");
expect(contents.endsWith("\n")).toBe(true);
expect(JSON.parse(contents)).toEqual(written.marker);
} finally {
await rm(workspaceDir, { recursive: true, force: true });
}
});
it("records force:true in the marker without acting on it", async () => {
const writes: Array<{ path: string; contents: string }> = [];
const fs = {
mkdir: vi.fn(async () => undefined),
writeFile: vi.fn(async (path: string, contents: string) => {
writes.push({ path, contents });
}),
};
const written = await writeOpenClawCompactionMarker(
{
sessionId: "s1",
workspaceDir: "/ws",
force: true,
reason: "force-requested-but-sdk-has-no-synchronous-compact-api",
},
{ now: () => 1, fs: fs as never },
);
expect(written.marker.force).toBe(true);
expect(written.marker.compacted).toBe(false);
expect(writes).toHaveLength(1);
expect(JSON.parse(writes[0].contents)).toMatchObject({ force: true });
});
it("omits force / trigger / sdkSessionId / currentTokenCount when undefined", async () => {
const writes: Array<{ path: string; contents: string }> = [];
const fs = {
mkdir: vi.fn(async () => undefined),
writeFile: vi.fn(async (path: string, contents: string) => {
writes.push({ path, contents });
}),
};
const written = await writeOpenClawCompactionMarker(
{ sessionId: "s1", workspaceDir: "/ws" },
{ now: () => 7, fs: fs as never },
);
expect(written.marker).toEqual({
version: 1,
source: "copilot-harness",
sessionId: "s1",
ts: 7,
compacted: false,
});
const parsed = JSON.parse(writes[0].contents);
expect(parsed).not.toHaveProperty("force");
expect(parsed).not.toHaveProperty("trigger");
expect(parsed).not.toHaveProperty("sdkSessionId");
expect(parsed).not.toHaveProperty("currentTokenCount");
expect(parsed).not.toHaveProperty("reason");
});
it("sanitizes sessionId chars in the filename", async () => {
const fs = {
mkdir: vi.fn(async () => undefined),
writeFile: vi.fn(async () => undefined),
};
const written = await writeOpenClawCompactionMarker(
{ sessionId: "abc:/?\\@!def", workspaceDir: "/ws" },
{ now: () => 1, fs: fs as never },
);
expect(written.path).toContain("openclaw-compaction-1-abc______def.json");
// sessionId in the marker body stays the original unsanitized value.
expect(written.marker.sessionId).toBe("abc:/?\\@!def");
});
it("creates the subdir recursively before writing", async () => {
const calls: Array<{ kind: "mkdir" | "write"; path: string; opts?: unknown }> = [];
const fs = {
mkdir: vi.fn(async (path: string, opts: unknown) => {
calls.push({ kind: "mkdir", path, opts });
}),
writeFile: vi.fn(async (path: string) => {
calls.push({ kind: "write", path });
}),
};
await writeOpenClawCompactionMarker(
{ sessionId: "s", workspaceDir: "/ws" },
{ now: () => 1, fs: fs as never },
);
expect(calls[0]).toEqual({ kind: "mkdir", path: "/ws/files", opts: { recursive: true } });
expect(calls[1]?.kind).toBe("write");
});
it("honours a custom subdir option", async () => {
const fs = {
mkdir: vi.fn(async () => undefined),
writeFile: vi.fn(async () => undefined),
};
const written = await writeOpenClawCompactionMarker(
{ sessionId: "s", workspaceDir: "/ws" },
{ now: () => 1, fs: fs as never, subdir: "compaction" },
);
expect(written.path).toBe("/ws/compaction/openclaw-compaction-1-s.json");
});
it("surfaces mkdir failures", async () => {
const fs = {
mkdir: vi.fn(async () => {
throw new Error("EACCES");
}),
writeFile: vi.fn(async () => undefined),
};
await expect(
writeOpenClawCompactionMarker(
{ sessionId: "s", workspaceDir: "/ws" },
{ now: () => 1, fs: fs as never },
),
).rejects.toThrow("EACCES");
expect(fs.writeFile).not.toHaveBeenCalled();
});
it("surfaces writeFile failures", async () => {
const fs = {
mkdir: vi.fn(async () => undefined),
writeFile: vi.fn(async () => {
throw new Error("ENOSPC");
}),
};
await expect(
writeOpenClawCompactionMarker(
{ sessionId: "s", workspaceDir: "/ws" },
{ now: () => 1, fs: fs as never },
),
).rejects.toThrow("ENOSPC");
});
it("throws on missing sessionId", async () => {
await expect(
writeOpenClawCompactionMarker({ sessionId: "", workspaceDir: "/ws" }),
).rejects.toThrow(/sessionId is required/);
});
it("throws on missing workspaceDir", async () => {
await expect(
writeOpenClawCompactionMarker({ sessionId: "s", workspaceDir: "" }),
).rejects.toThrow(/workspaceDir is required/);
});
});

View File

@@ -1,25 +1,11 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { SessionConfig } from "@github/copilot-sdk";
// Compaction bridge for the GitHub Copilot agent runtime.
//
// Two responsibilities:
//
// 1. Shape `SessionConfig.infiniteSessions` from a typed options bag
// so attempt.ts can opt the SDK in to background auto-compaction
// at session creation. The SDK manages the actual compaction
// under the `infiniteSessions` config (background at
// `backgroundCompactionThreshold`, blocking at
// `bufferExhaustionThreshold`).
//
// 2. Write an OpenClaw-shaped JSON marker file at
// `<workspaceDir>/files/openclaw-compaction-<sessionId>-<ts>.json`
// whenever the host calls `harness.compact(params)`. Existing
// OpenClaw transcript readers look in `workspacePath/files/` for
// compaction artifacts; the marker keeps them informed even
// though the SDK now owns the actual context-window mechanics
// under infiniteSessions.
// Shapes `SessionConfig.infiniteSessions` from a typed options bag so
// attempt.ts can opt the SDK in to background auto-compaction at session
// creation. The SDK manages the actual compaction under the `infiniteSessions`
// config and the session-scoped history compaction RPC.
//
// Host back-pointers (NOT imported here to keep the package boundary
// clean):
@@ -64,120 +50,3 @@ export function createInfiniteSessionConfig(
}
return Object.keys(result).length > 0 ? result : undefined;
}
export interface OpenClawCompactionMarkerInput {
/** OpenClaw session id (CompactEmbeddedPiSessionParams.sessionId). */
readonly sessionId: string;
/** Workspace dir (CompactEmbeddedPiSessionParams.workspaceDir). */
readonly workspaceDir: string;
/** Compaction trigger from CompactEmbeddedPiSessionParams.trigger. */
readonly trigger?: "budget" | "overflow" | "manual";
/** Optional caller-observed token count at compaction time. */
readonly currentTokenCount?: number;
/** Optional active SDK session id when the marker is written. */
readonly sdkSessionId?: string;
/** Optional reason string for the marker. */
readonly reason?: string;
/**
* Whether the host passed `force: true` in CompactEmbeddedPiSessionParams.
* Recorded for diagnostics — the harness cannot synchronously force
* compaction since the SDK has no on-demand compact RPC.
*/
readonly force?: boolean;
}
export interface OpenClawCompactionMarkerOptions {
/** Override `Date.now`. Default: `Date.now`. */
readonly now?: () => number;
/** Override `node:fs/promises` writers. Useful in tests. */
readonly fs?: Pick<typeof import("node:fs/promises"), "mkdir" | "writeFile">;
/**
* Subdirectory under workspaceDir that holds the markers. Default
* `files` to match the proposal-defined location.
*/
readonly subdir?: string;
}
export interface OpenClawCompactionMarker {
readonly version: 1;
readonly source: "copilot-harness";
readonly sessionId: string;
readonly ts: number;
/**
* Whether actual compaction occurred. Always false from the harness
* path: SDK auto-compaction runs asynchronously in the background
* and the harness does not synchronously force it.
*/
readonly compacted: false;
readonly trigger?: "budget" | "overflow" | "manual";
readonly force?: boolean;
readonly sdkSessionId?: string;
readonly currentTokenCount?: number;
readonly reason?: string;
}
export interface WrittenOpenClawCompactionMarker {
readonly path: string;
readonly marker: OpenClawCompactionMarker;
}
function compactJsonValue<T extends Record<string, unknown>>(input: T): T {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input)) {
if (value !== undefined) {
out[key] = value;
}
}
return out as T;
}
/**
* Write an OpenClaw-shaped compaction marker JSON file under
* `<workspaceDir>/<subdir>/openclaw-compaction-<sessionId>-<ts>.json`.
*
* Returns the resolved file path and the marker payload that was
* written. Throws if the workspaceDir or sessionId is missing/empty
* (the caller should not invoke this without those — the harness
* `compact()` must validate first).
*/
export async function writeOpenClawCompactionMarker(
input: OpenClawCompactionMarkerInput,
options: OpenClawCompactionMarkerOptions = {},
): Promise<WrittenOpenClawCompactionMarker> {
if (!input.workspaceDir || typeof input.workspaceDir !== "string") {
throw new Error("[copilot:compaction-bridge] workspaceDir is required to write a marker");
}
if (!input.sessionId || typeof input.sessionId !== "string") {
throw new Error("[copilot:compaction-bridge] sessionId is required to write a marker");
}
const now = options.now ?? Date.now;
const fs = options.fs ?? { mkdir, writeFile };
const subdir = options.subdir ?? "files";
const ts = now();
const safeSessionId = input.sessionId.replace(/[^a-zA-Z0-9._-]/g, "_");
// Filename pattern: ts-first so listings sort chronologically. Suffix
// sessionId for collision safety when multiple sessions share a
// workspace. Matches the proposal's `openclaw-compaction-<ts>` prefix.
const filename = `openclaw-compaction-${ts}-${safeSessionId}.json`;
const dirPath = join(input.workspaceDir, subdir);
const filePath = join(dirPath, filename);
const marker: OpenClawCompactionMarker = compactJsonValue({
version: 1 as const,
source: "copilot-harness" as const,
sessionId: input.sessionId,
ts,
compacted: false as const,
trigger: input.trigger,
force: input.force,
sdkSessionId: input.sdkSessionId,
currentTokenCount: input.currentTokenCount,
reason: input.reason,
});
await fs.mkdir(dirPath, { recursive: true });
await fs.writeFile(filePath, `${JSON.stringify(marker, null, 2)}\n`, "utf8");
return { path: filePath, marker };
}

View File

@@ -223,6 +223,6 @@ describe("sdk dependency constants", () => {
expect(COPILOT_SDK_FALLBACK_DIR).toMatch(/\.openclaw[\\/]+npm-runtime[\\/]+copilot$/);
});
it("COPILOT_SDK_SPEC pins the canonical SDK spec", () => {
expect(COPILOT_SDK_SPEC).toBe("@github/copilot-sdk@1.0.0-beta.4");
expect(COPILOT_SDK_SPEC).toBe("@github/copilot-sdk@1.0.0-beta.9");
});
});

View File

@@ -11,7 +11,7 @@ export function resolveCopilotSdkFallbackDir(env: NodeJS.ProcessEnv = process.en
export const COPILOT_SDK_FALLBACK_DIR = resolveCopilotSdkFallbackDir();
export const COPILOT_SDK_SPEC = "@github/copilot-sdk@1.0.0-beta.4";
export const COPILOT_SDK_SPEC = "@github/copilot-sdk@1.0.0-beta.9";
let cached: Promise<typeof Sdk> | undefined;

View File

@@ -280,6 +280,8 @@ describe("runCliTurnCompactionLifecycle", () => {
totalTokens: 950,
totalTokensFresh: true,
agentHarnessId: "codex",
authProfileOverride: "github-copilot:work",
authProfileOverrideSource: "auto",
};
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
@@ -368,9 +370,13 @@ describe("runCliTurnCompactionLifecycle", () => {
currentTokenCount: 950,
contextEngine,
agentHarnessId: "codex",
authProfileId: "github-copilot:work",
trigger: "budget",
force: true,
});
expect(compactAgentHarnessSessionCalls[0]?.[0].contextEngineRuntimeContext).toMatchObject({
authProfileId: "github-copilot:work",
});
expect(compactCalls).toHaveLength(0);
expect(recordCliCompactionInStore).toHaveBeenCalledTimes(1);
expect(recordCliCompactionInStore).toHaveBeenCalledWith(
@@ -383,11 +389,11 @@ describe("runCliTurnCompactionLifecycle", () => {
expect(updatedEntry?.compactionCount).toBe(1);
});
it("treats below-target Codex native CLI compaction as a no-op", async () => {
const sessionKey = "agent:main:codex-under-target";
const sessionId = "session-codex-under-target";
const sessionFile = path.join(tmpDir, "session-codex-under-target.jsonl");
const storePath = path.join(tmpDir, "sessions-codex-under-target.json");
it("treats below-target Copilot native CLI compaction as a no-op", async () => {
const sessionKey = "agent:main:copilot-under-target";
const sessionId = "session-copilot-under-target";
const sessionFile = path.join(tmpDir, "session-copilot-under-target.jsonl");
const storePath = path.join(tmpDir, "sessions-copilot-under-target.json");
await writeSessionFile({ sessionFile, sessionId });
const sessionEntry: SessionEntry = {
@@ -397,7 +403,7 @@ describe("runCliTurnCompactionLifecycle", () => {
contextTokens: 1_000,
totalTokens: 950,
totalTokensFresh: true,
agentHarnessId: "codex",
agentHarnessId: "copilot",
};
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
@@ -441,7 +447,7 @@ describe("runCliTurnCompactionLifecycle", () => {
sessionAgentId: "main",
workspaceDir: tmpDir,
agentDir: tmpDir,
provider: "codex",
provider: "github-copilot",
model: "gpt-5.5",
});
@@ -934,6 +940,8 @@ describe("runCliTurnCompactionLifecycle", () => {
totalTokens: 950,
totalTokensFresh: true,
agentHarnessId: "codex",
authProfileOverride: "github-copilot:work",
authProfileOverrideSource: "auto",
};
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
@@ -992,6 +1000,9 @@ describe("runCliTurnCompactionLifecycle", () => {
expect(compactAgentHarnessSession).toHaveBeenCalledTimes(1);
expect(compactCalls).toHaveLength(1);
expect(compactCalls[0]?.runtimeContext).toMatchObject({
authProfileId: "github-copilot:work",
});
expect(maintenance).toHaveBeenCalledTimes(1);
expect(recordCliCompactionInStore).toHaveBeenCalledWith(
expect.objectContaining({

View File

@@ -87,6 +87,7 @@ type CliCompactionRuntimeContextParams = {
sessionKey: string;
messageChannel?: string;
agentAccountId?: string;
authProfileId?: string;
workspaceDir: string;
cwd?: string;
agentDir: string;
@@ -174,8 +175,8 @@ function isNativeHarnessCompactionSession(
const providerId = provider.trim().toLowerCase();
return (
harnessId === providerId ||
(harnessId === "codex" &&
(providerId === "codex" || providerId === "openai" || providerId === "openai"))
(harnessId === "copilot" && providerId === "github-copilot") ||
(harnessId === "codex" && (providerId === "codex" || providerId === "openai"))
);
}
@@ -211,7 +212,7 @@ function buildCliCompactionRuntimeContext(params: CliCompactionRuntimeContextPar
messageChannel: params.messageChannel,
messageProvider: params.messageChannel,
agentAccountId: params.agentAccountId,
authProfileId: undefined,
authProfileId: params.authProfileId,
workspaceDir: params.workspaceDir,
cwd: params.cwd,
agentDir: params.agentDir,
@@ -246,6 +247,7 @@ async function compactCliTranscript(params: {
skillsSnapshot?: SkillSnapshot;
messageChannel?: string;
agentAccountId?: string;
authProfileId?: string;
senderIsOwner?: boolean;
thinkLevel?: Parameters<typeof buildEmbeddedCompactionRuntimeContext>[0]["thinkLevel"];
extraSystemPrompt?: string;
@@ -255,6 +257,7 @@ async function compactCliTranscript(params: {
sessionKey: params.sessionKey,
messageChannel: params.messageChannel,
agentAccountId: params.agentAccountId,
authProfileId: params.authProfileId,
workspaceDir: params.workspaceDir,
cwd: params.cwd,
agentDir: params.agentDir,
@@ -360,6 +363,7 @@ async function compactNativeHarnessCliTranscript(params: {
try {
const sessionAgentId = readAgentIdFromSessionKey(params.sessionKey);
const nativeHarnessId = params.sessionEntry.agentHarnessId?.trim();
const authProfileId = params.sessionEntry.authProfileOverride?.trim() || undefined;
await cliCompactionDeps.ensureSelectedAgentHarnessPlugin({
provider: params.provider,
modelId: params.model,
@@ -382,6 +386,7 @@ async function compactNativeHarnessCliTranscript(params: {
skillsSnapshot: params.skillsSnapshot,
provider: params.provider,
model: params.model,
authProfileId,
contextTokenBudget: params.contextTokenBudget,
currentTokenCount: params.currentTokenCount,
trigger: "budget",
@@ -399,6 +404,7 @@ async function compactNativeHarnessCliTranscript(params: {
sessionKey: params.sessionKey,
messageChannel: params.messageChannel,
agentAccountId: params.agentAccountId,
authProfileId,
workspaceDir: params.workspaceDir,
cwd: params.cwd,
agentDir: params.agentDir,
@@ -526,6 +532,7 @@ export async function runCliTurnCompactionLifecycle(params: {
let nativeFallbackNeedsBindingClear = false;
let resolvedContextEngine: ContextEngine | undefined;
let autoCompactionGuardApplied = false;
const authProfileId = params.sessionEntry?.authProfileOverride?.trim() || undefined;
const applyAutoCompactionGuard = async (contextEngine: ContextEngine): Promise<void> => {
if (autoCompactionGuardApplied) {
return;
@@ -606,6 +613,7 @@ export async function runCliTurnCompactionLifecycle(params: {
skillsSnapshot: params.skillsSnapshot,
messageChannel: params.messageChannel,
agentAccountId: params.agentAccountId,
authProfileId,
senderIsOwner: params.senderIsOwner,
thinkLevel: params.thinkLevel,
extraSystemPrompt: params.extraSystemPrompt,

View File

@@ -27,6 +27,8 @@ export type CompactEmbeddedAgentSessionParams = {
senderUsername?: string;
senderE164?: string;
authProfileId?: string;
/** Host-resolved provider credential for native harness compaction. */
resolvedApiKey?: string;
/** Group id for channel-level tool policy resolution. */
groupId?: string | null;
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */

View File

@@ -21,6 +21,10 @@ import type { AgentHarness } from "./types.js";
const agentRunAttempt = vi.fn<AgentHarness["runAttempt"]>(async () =>
createAttemptResult("openclaw"),
);
const compactAuthMocks = vi.hoisted(() => ({
getApiKeyForModel: vi.fn(),
resolveModelAsync: vi.fn(),
}));
vi.mock("./builtin-openclaw.js", () => ({
createOpenClawAgentHarness: (): AgentHarness => ({
@@ -31,11 +35,21 @@ vi.mock("./builtin-openclaw.js", () => ({
runAttempt: agentRunAttempt,
}),
}));
vi.mock("../model-auth.js", () => ({
getApiKeyForModel: compactAuthMocks.getApiKeyForModel,
}));
vi.mock("../embedded-agent-runner/model.js", () => ({
resolveModelAsync: compactAuthMocks.resolveModelAsync,
}));
const originalRuntime = process.env.OPENCLAW_AGENT_RUNTIME;
beforeEach(() => {
clearAgentHarnesses();
compactAuthMocks.resolveModelAsync.mockResolvedValue({
model: { id: "gpt-5.5", provider: "openai" },
});
compactAuthMocks.getApiKeyForModel.mockResolvedValue({ apiKey: "test-key" });
cliBackendsTesting.setDepsForTest({
resolvePluginSetupRegistry: () => ({
providers: [],
@@ -65,6 +79,8 @@ afterEach(() => {
clearAgentHarnesses();
cliBackendsTesting.resetDepsForTest();
agentRunAttempt.mockClear();
compactAuthMocks.resolveModelAsync.mockReset();
compactAuthMocks.getApiKeyForModel.mockReset();
if (originalRuntime == null) {
delete process.env.OPENCLAW_AGENT_RUNTIME;
} else {
@@ -757,7 +773,10 @@ describe("selectAgentHarness", () => {
});
it("honors selected plugin harness pins during compaction preflight", async () => {
const compact = vi.fn(async () => ({ ok: true, compacted: false }));
const compact = vi.fn<NonNullable<AgentHarness["compact"]>>(async () => ({
ok: true,
compacted: false,
}));
registerAgentHarness(
{
id: "codex",
@@ -779,10 +798,68 @@ describe("selectAgentHarness", () => {
provider: "openai",
model: "gpt-5.5",
agentHarnessId: "codex",
config: {
agents: {
list: [{ id: "main", default: true, agentDir: "/tmp/main-agent" }],
defaults: {
models: {
"openai/gpt-5.5": { agentRuntime: { id: "openclaw" } },
},
},
},
} as OpenClawConfig,
}),
).resolves.toEqual({ ok: true, compacted: false });
expect(compact).toHaveBeenCalledTimes(1);
expect(compact.mock.calls[0]?.[0]).toMatchObject({
agentDir: "/tmp/main-agent",
agentId: "main",
});
});
it("keeps compaction recoverable when auth profile lookup fails", async () => {
compactAuthMocks.getApiKeyForModel.mockRejectedValue(new Error("missing auth profile"));
const compact = vi.fn<NonNullable<AgentHarness["compact"]>>(async () => ({
ok: true,
compacted: false,
}));
registerAgentHarness(
{
id: "codex",
label: "Codex",
supports: (ctx) =>
ctx.provider === "openai" ? { supported: true, priority: 100 } : { supported: false },
runAttempt: vi.fn(async () => createAttemptResult("codex")),
compact,
},
{ ownerPluginId: "codex" },
);
await expect(
maybeCompactAgentHarnessSession({
sessionId: "session-1",
sessionKey: "agent:main:main",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp/workspace",
provider: "openai",
model: "gpt-5.5",
authProfileId: "deleted-profile",
agentHarnessId: "codex",
config: agentModelRuntimeConfig("openai/gpt-5.5", "openclaw"),
}),
).resolves.toEqual({ ok: true, compacted: false });
expect(compact).toHaveBeenCalledTimes(1);
expect(compact.mock.calls[0]?.[0]).not.toHaveProperty("resolvedApiKey");
expect(compactAuthMocks.resolveModelAsync).toHaveBeenCalledWith(
"openai",
"gpt-5.5",
expect.any(String),
expect.any(Object),
expect.objectContaining({
authProfileId: "deleted-profile",
workspaceDir: "/tmp/workspace",
}),
);
});
it("does not compact a selected plugin harness through OpenClaw when the plugin has no compactor", async () => {
@@ -807,7 +884,10 @@ describe("selectAgentHarness", () => {
});
it("uses agent-scoped runtime policy during compaction preflight", async () => {
const compact = vi.fn(async () => ({ ok: true, compacted: false }));
const compact = vi.fn<NonNullable<AgentHarness["compact"]>>(async () => ({
ok: true,
compacted: false,
}));
registerAgentHarness(
{
id: "codex",
@@ -836,7 +916,10 @@ describe("selectAgentHarness", () => {
});
it("uses sandbox session key for compaction preflight runtime policy", async () => {
const compact = vi.fn(async () => ({ ok: true, compacted: false }));
const compact = vi.fn<NonNullable<AgentHarness["compact"]>>(async () => ({
ok: true,
compacted: false,
}));
registerAgentHarness(
{
id: "codex",
@@ -863,10 +946,14 @@ describe("selectAgentHarness", () => {
}),
).resolves.toEqual({ ok: true, compacted: false });
expect(compact).toHaveBeenCalledTimes(1);
expect(compact.mock.calls[0]?.[0]).toMatchObject({ agentId: "main" });
});
it("keeps explicit agent id for non-agent sandbox policy keys during compaction preflight", async () => {
const compact = vi.fn(async () => ({ ok: true, compacted: false }));
const compact = vi.fn<NonNullable<AgentHarness["compact"]>>(async () => ({
ok: true,
compacted: false,
}));
registerAgentHarness(
{
id: "codex",

View File

@@ -2,7 +2,9 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { resolveUserPath } from "../../utils.js";
import { isDefaultAgentRuntimeId, normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js";
import { resolveAgentDir, resolveSessionAgentIds } from "../agent-scope.js";
import {
resolveEffectiveToolPolicy,
resolveGroupToolPolicy,
@@ -10,11 +12,13 @@ import {
resolveSubagentToolPolicyForSession,
} from "../agent-tools.policy.js";
import type { CompactEmbeddedAgentSessionParams } from "../embedded-agent-runner/compact.types.js";
import { resolveModelAsync } from "../embedded-agent-runner/model.js";
import type {
EmbeddedRunAttemptParams,
EmbeddedRunAttemptResult,
} from "../embedded-agent-runner/run/types.js";
import type { EmbeddedAgentCompactResult } from "../embedded-agent-runner/types.js";
import { getApiKeyForModel } from "../model-auth.js";
import { isCliRuntimeAliasForProvider, isCliRuntimeProvider } from "../model-runtime-aliases.js";
import { resolveSandboxRuntimeStatus } from "../sandbox/runtime-status.js";
import { resolveSenderToolPolicy } from "../sender-tool-policy.js";
@@ -485,6 +489,61 @@ function logAgentHarnessSelection(
});
}
function resolveHarnessCompactIdentity(params: CompactEmbeddedAgentSessionParams): {
agentDir: string;
agentId: string;
} {
const agentIds = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
agentId: params.agentId,
});
return {
agentDir: params.agentDir ?? resolveAgentDir(params.config ?? {}, agentIds.sessionAgentId),
agentId: params.agentId ?? agentIds.sessionAgentId,
};
}
async function resolveHarnessCompactApiKey(params: {
agentDir: string;
compactParams: CompactEmbeddedAgentSessionParams;
}): Promise<string | undefined> {
const { agentDir, compactParams } = params;
const existing = compactParams.resolvedApiKey?.trim();
if (existing) {
return existing;
}
if (
!compactParams.authProfileId?.trim() ||
!compactParams.provider?.trim() ||
!compactParams.model?.trim()
) {
return undefined;
}
const workspaceDir = resolveUserPath(compactParams.workspaceDir);
const { model } = await resolveModelAsync(
compactParams.provider,
compactParams.model,
agentDir,
compactParams.config,
{
authProfileId: compactParams.authProfileId,
workspaceDir,
},
);
if (!model) {
return undefined;
}
const apiKeyInfo = await getApiKeyForModel({
model,
cfg: compactParams.config,
profileId: compactParams.authProfileId,
agentDir,
workspaceDir,
});
return apiKeyInfo.apiKey?.trim() || undefined;
}
export async function maybeCompactAgentHarnessSession(
params: CompactEmbeddedAgentSessionParams,
): Promise<EmbeddedAgentCompactResult | undefined> {
@@ -539,7 +598,24 @@ export async function maybeCompactAgentHarnessSession(
}
return undefined;
}
return harness.compact(params);
const compactIdentity = resolveHarnessCompactIdentity(params);
const compactParams = {
...params,
agentDir: compactIdentity.agentDir,
agentId: compactIdentity.agentId,
};
let resolvedApiKey: string | undefined;
try {
resolvedApiKey = await resolveHarnessCompactApiKey({
agentDir: compactIdentity.agentDir,
compactParams,
});
} catch (err) {
log.debug("agent harness compaction credential lookup failed", {
error: formatErrorMessage(err),
});
}
return harness.compact(resolvedApiKey ? { ...compactParams, resolvedApiKey } : compactParams);
}
function formatProviderModel(params: { provider: string; modelId?: string }): string {
return params.modelId ? `${params.provider}/${params.modelId}` : params.provider;

View File

@@ -165,6 +165,7 @@ describe("handleCompactCommand", () => {
space: "workspace-1",
spawnedBy: "agent:main:parent",
totalTokens: 12345,
authProfileOverride: "github-copilot:work",
},
} as HandleCommandsParams,
true,
@@ -188,6 +189,7 @@ describe("handleCompactCommand", () => {
expect(call.senderUsername).toBe("alice_u");
expect(call.senderE164).toBe("+15551234567");
expect(call.agentDir).toBe("/tmp/openclaw-agent-compact");
expect(call.authProfileId).toBe("github-copilot:work");
});
it("treats already-under-target manual compaction as skipped", async () => {

View File

@@ -273,6 +273,7 @@ export const handleCompactCommand: CommandHandler = async (params) => {
skillsSnapshot: targetSessionEntry.skillsSnapshot,
provider: params.provider,
model: params.model,
authProfileId: targetSessionEntry.authProfileOverride,
contextTokenBudget,
agentHarnessId:
targetSessionEntry.sessionId === sessionId ? targetSessionEntry.agentHarnessId : undefined,

View File

@@ -2648,6 +2648,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
config: cfg,
provider: resolvedModel.provider,
model: resolvedModel.model,
authProfileId: entry?.authProfileOverride,
agentHarnessId: entry?.sessionId === sessionId ? entry.agentHarnessId : undefined,
thinkLevel: normalizeThinkLevel(entry?.thinkingLevel),
reasoningLevel: normalizeReasoningLevel(entry?.reasoningLevel),

View File

@@ -710,7 +710,12 @@ test("sessions.compact scopes selected global truncation to the requested agent"
await fs.writeFile(
workStorePath,
JSON.stringify(
{ global: sessionStoreEntry("sess-work-global", { sessionFile: workTranscript }) },
{
global: sessionStoreEntry("sess-work-global", {
authProfileOverride: "github-copilot:work",
sessionFile: workTranscript,
}),
},
null,
2,
),
@@ -799,7 +804,12 @@ test("sessions.compact passes the selected global agent into embedded compaction
await fs.writeFile(
workStorePath,
JSON.stringify(
{ global: sessionStoreEntry("sess-work-global", { sessionFile: workTranscript }) },
{
global: sessionStoreEntry("sess-work-global", {
authProfileOverride: "github-copilot:work",
sessionFile: workTranscript,
}),
},
null,
2,
),
@@ -851,6 +861,7 @@ test("sessions.compact passes the selected global agent into embedded compaction
sessionId: "sess-work-global",
sessionKey: "global",
agentId: "work",
authProfileId: "github-copilot:work",
});
testState.sessionStorePath = undefined;
testState.sessionConfig = undefined;

View File

@@ -286,6 +286,7 @@ export {
// timeout the built-in embedded-agent runner uses — one shared implementation, no
// copy-pasted watchdog.
export {
compactWithSafetyTimeout,
compactContextEngineWithSafetyTimeout,
resolveCompactionTimeoutMs,
} from "../agents/embedded-agent-runner/compaction-safety-timeout.js";