From db4990d2605ba00ae9c626d69bc4e2fd9d41c68c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 1 Jun 2026 01:18:46 -0400 Subject: [PATCH] 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. --- docs/plugins/copilot.md | 9 +- docs/refactor/database-first.md | 4 + extensions/copilot/harness.test.ts | 886 +++++++++++++++--- extensions/copilot/harness.ts | 383 ++++++-- extensions/copilot/src/attempt.ts | 39 +- .../copilot/src/compaction-bridge.test.ts | 188 +--- extensions/copilot/src/compaction-bridge.ts | 139 +-- extensions/copilot/src/sdk-loader.test.ts | 2 +- extensions/copilot/src/sdk-loader.ts | 2 +- src/agents/command/cli-compaction.test.ts | 25 +- src/agents/command/cli-compaction.ts | 14 +- .../embedded-agent-runner/compact.types.ts | 2 + src/agents/harness/selection.test.ts | 95 +- src/agents/harness/selection.ts | 78 +- src/auto-reply/reply/commands-compact.test.ts | 2 + src/auto-reply/reply/commands-compact.ts | 1 + src/gateway/server-methods/sessions.ts | 1 + .../server.sessions.list-changed.test.ts | 15 +- src/plugin-sdk/agent-harness-runtime.ts | 1 + 19 files changed, 1353 insertions(+), 533 deletions(-) diff --git a/docs/plugins/copilot.md b/docs/plugins/copilot.md index 4c89d7d13d01..259e633981c7 100755 --- a/docs/plugins/copilot.md +++ b/docs/plugins/copilot.md @@ -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-.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. diff --git a/docs/refactor/database-first.md b/docs/refactor/database-first.md index fa8582671e0d..73ed86ee2f58 100644 --- a/docs/refactor/database-first.md +++ b/docs/refactor/database-first.md @@ -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 diff --git a/extensions/copilot/harness.test.ts b/extensions/copilot/harness.test.ts index 9e1a7503ba65..f01a68b77156 100644 --- a/extensions/copilot/harness.test.ts +++ b/extensions/copilot/harness.test.ts @@ -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 = {}): 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 = {}): 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 /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((_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", + }); }); }); diff --git a/extensions/copilot/harness.ts b/extensions/copilot/harness.ts index 45562ecc5cb9..b2648284183c 100644 --- a/extensions/copilot/harness.ts +++ b/extensions/copilot/harness.ts @@ -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; + disconnect(): Promise; + rpc: { + history: { + abortManualCompaction(): Promise<{ aborted: boolean }>; + compact(params?: { customInstructions?: string }): Promise; + }; + }; } 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; + type CopilotSessionBindingStore = Pick< PluginStateSyncKeyedStore, "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 { + 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 { - // 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 - // `/files/openclaw-compaction--.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[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", }; }, diff --git a/extensions/copilot/src/attempt.ts b/extensions/copilot/src/attempt.ts index d37dad636648..bc3af4962608 100644 --- a/extensions/copilot/src/attempt.ts +++ b/extensions/copilot/src/attempt.ts @@ -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); diff --git a/extensions/copilot/src/compaction-bridge.test.ts b/extensions/copilot/src/compaction-bridge.test.ts index 1d387b1532be..2d4280feb2af 100755 --- a/extensions/copilot/src/compaction-bridge.test.ts +++ b/extensions/copilot/src/compaction-bridge.test.ts @@ -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 /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/); - }); -}); diff --git a/extensions/copilot/src/compaction-bridge.ts b/extensions/copilot/src/compaction-bridge.ts index c4b1e96627c9..af006ce0584b 100755 --- a/extensions/copilot/src/compaction-bridge.ts +++ b/extensions/copilot/src/compaction-bridge.ts @@ -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 -// `/files/openclaw-compaction--.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; - /** - * 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>(input: T): T { - const out: Record = {}; - 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 - * `//openclaw-compaction--.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 { - 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-` 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 }; -} diff --git a/extensions/copilot/src/sdk-loader.test.ts b/extensions/copilot/src/sdk-loader.test.ts index e94bcc5f64d3..f8f48fe23a3f 100755 --- a/extensions/copilot/src/sdk-loader.test.ts +++ b/extensions/copilot/src/sdk-loader.test.ts @@ -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"); }); }); diff --git a/extensions/copilot/src/sdk-loader.ts b/extensions/copilot/src/sdk-loader.ts index eaba46af154b..cb8feabd4bcc 100755 --- a/extensions/copilot/src/sdk-loader.ts +++ b/extensions/copilot/src/sdk-loader.ts @@ -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 | undefined; diff --git a/src/agents/command/cli-compaction.test.ts b/src/agents/command/cli-compaction.test.ts index a09491239cb3..cedd43d76e2d 100644 --- a/src/agents/command/cli-compaction.test.ts +++ b/src/agents/command/cli-compaction.test.ts @@ -280,6 +280,8 @@ describe("runCliTurnCompactionLifecycle", () => { totalTokens: 950, totalTokensFresh: true, agentHarnessId: "codex", + authProfileOverride: "github-copilot:work", + authProfileOverrideSource: "auto", }; const sessionStore: Record = { [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 = { [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 = { [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({ diff --git a/src/agents/command/cli-compaction.ts b/src/agents/command/cli-compaction.ts index 6e76a89b4fa7..bb86cc6d3c91 100644 --- a/src/agents/command/cli-compaction.ts +++ b/src/agents/command/cli-compaction.ts @@ -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[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 => { 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, diff --git a/src/agents/embedded-agent-runner/compact.types.ts b/src/agents/embedded-agent-runner/compact.types.ts index f161cd8ad908..7dad8233a2be 100644 --- a/src/agents/embedded-agent-runner/compact.types.ts +++ b/src/agents/embedded-agent-runner/compact.types.ts @@ -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. */ diff --git a/src/agents/harness/selection.test.ts b/src/agents/harness/selection.test.ts index 3f9a4801d94a..315a02e5ed58 100644 --- a/src/agents/harness/selection.test.ts +++ b/src/agents/harness/selection.test.ts @@ -21,6 +21,10 @@ import type { AgentHarness } from "./types.js"; const agentRunAttempt = vi.fn(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>(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>(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>(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>(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>(async () => ({ + ok: true, + compacted: false, + })); registerAgentHarness( { id: "codex", diff --git a/src/agents/harness/selection.ts b/src/agents/harness/selection.ts index 09b0a4a003f1..936e711710e8 100644 --- a/src/agents/harness/selection.ts +++ b/src/agents/harness/selection.ts @@ -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 { + 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 { @@ -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; diff --git a/src/auto-reply/reply/commands-compact.test.ts b/src/auto-reply/reply/commands-compact.test.ts index 95c2fda9e5c8..f8aa41c2a0ec 100644 --- a/src/auto-reply/reply/commands-compact.test.ts +++ b/src/auto-reply/reply/commands-compact.test.ts @@ -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 () => { diff --git a/src/auto-reply/reply/commands-compact.ts b/src/auto-reply/reply/commands-compact.ts index 646dcc5e73cd..4f56694e35d9 100644 --- a/src/auto-reply/reply/commands-compact.ts +++ b/src/auto-reply/reply/commands-compact.ts @@ -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, diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 2856ad67e954..2bb78aff421f 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -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), diff --git a/src/gateway/server.sessions.list-changed.test.ts b/src/gateway/server.sessions.list-changed.test.ts index cc168679462b..a9246f43debd 100644 --- a/src/gateway/server.sessions.list-changed.test.ts +++ b/src/gateway/server.sessions.list-changed.test.ts @@ -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; diff --git a/src/plugin-sdk/agent-harness-runtime.ts b/src/plugin-sdk/agent-harness-runtime.ts index 35a9aa854bb2..682dfc2008ab 100644 --- a/src/plugin-sdk/agent-harness-runtime.ts +++ b/src/plugin-sdk/agent-harness-runtime.ts @@ -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";