mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
committed by
GitHub
parent
4550cfa6a7
commit
db4990d260
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user