























@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
33import { join } from "node:path";
44import { beforeEach, describe, expect, it, vi } from "vitest";
55import type { CopilotClientPool } from "./harness.js";
6-import { createCopilotAgentHarness } from "./harness.js";
6+import { createCopilotAgentHarness, type CopilotSessionBinding } from "./harness.js";
7788const mocks = vi.hoisted(() => ({
99runCopilotAttempt: vi.fn(),
@@ -30,6 +30,20 @@ function makePoolMock(): CopilotClientPool {
3030};
3131}
323233+function makeSessionStoreMock() {
34+const entries = new Map<string, CopilotSessionBinding>();
35+return {
36+ entries,
37+store: {
38+register: vi.fn((key: string, value: CopilotSessionBinding) => {
39+entries.set(key, value);
40+}),
41+lookup: vi.fn((key: string) => entries.get(key)),
42+delete: vi.fn((key: string) => entries.delete(key)),
43+},
44+};
45+}
46+3347function createDeferred<T>() {
3448let resolve!: (value: T | PromiseLike<T>) => void;
3549let reject!: (reason?: unknown) => void;
@@ -736,6 +750,289 @@ describe("createCopilotAgentHarness", () => {
736750// the stale first-turn id.
737751expect(deleteSession).toHaveBeenCalledWith("sdk-sess-2");
738752});
753+754+it("persists sdkSessionId in plugin state and resumes it from a new harness instance", async () => {
755+const firstPool = makePoolMock();
756+const secondPool = makePoolMock();
757+const sessionStore = makeSessionStoreMock();
758+mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
759+deps.onSessionEstablished?.({
760+sdkSessionId: "sdk-sess-sqlite",
761+pooledClient: { key: {} as any, client: {} as any },
762+});
763+return ATTEMPT_RESULT;
764+});
765+const firstHarness = createCopilotAgentHarness({
766+pool: firstPool,
767+sessionStore: sessionStore.store,
768+});
769+const secondHarness = createCopilotAgentHarness({
770+pool: secondPool,
771+sessionStore: sessionStore.store,
772+});
773+774+await firstHarness.runAttempt(makeAttemptParams({ runId: "t1" }));
775+await secondHarness.runAttempt(makeAttemptParams({ runId: "t2" }));
776+777+expect(sessionStore.store.register).toHaveBeenCalledWith(
778+"oc-sess-reuse",
779+expect.objectContaining({
780+schemaVersion: 1,
781+sdkSessionId: "sdk-sess-sqlite",
782+}),
783+);
784+const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as {
785+initialReplayState?: { sdkSessionId?: string };
786+};
787+expect(secondCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-sqlite");
788+});
789+790+it("starts a fresh SDK session when persisted binding lookup fails", async () => {
791+const sessionStore = makeSessionStoreMock();
792+sessionStore.store.lookup.mockImplementation(() => {
793+throw new Error("sqlite read failed");
794+});
795+mocks.runCopilotAttempt.mockResolvedValue(ATTEMPT_RESULT);
796+const harness = createCopilotAgentHarness({
797+pool: makePoolMock(),
798+sessionStore: sessionStore.store,
799+});
800+801+await expect(harness.runAttempt(makeAttemptParams({ runId: "t1" }))).resolves.toBe(
802+ATTEMPT_RESULT,
803+);
804+805+const callParams = mocks.runCopilotAttempt.mock.calls[0]?.[0] as {
806+initialReplayState?: { sdkSessionId?: string };
807+};
808+expect(callParams.initialReplayState?.sdkSessionId).toBeUndefined();
809+expect(sessionStore.store.delete).toHaveBeenCalledWith("oc-sess-reuse");
810+});
811+812+it("keeps the in-memory binding when durable register fails", async () => {
813+const sessionStore = makeSessionStoreMock();
814+sessionStore.entries.set("oc-sess-reuse", {
815+schemaVersion: 1,
816+sdkSessionId: "sdk-sess-stale",
817+compatKey: "stale",
818+updatedAt: 1,
819+});
820+sessionStore.store.register.mockImplementation(() => {
821+throw new Error("sqlite write failed");
822+});
823+mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
824+deps.onSessionEstablished?.({
825+sdkSessionId: "sdk-sess-memory-only",
826+pooledClient: { key: {} as any, client: {} as any },
827+});
828+return ATTEMPT_RESULT;
829+});
830+const harness = createCopilotAgentHarness({
831+pool: makePoolMock(),
832+sessionStore: sessionStore.store,
833+});
834+835+await harness.runAttempt(makeAttemptParams({ runId: "t1" }));
836+await harness.runAttempt(makeAttemptParams({ runId: "t2" }));
837+838+const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as {
839+initialReplayState?: { sdkSessionId?: string };
840+};
841+expect(secondCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-memory-only");
842+expect(sessionStore.store.delete).toHaveBeenCalledWith("oc-sess-reuse");
843+expect(sessionStore.entries.has("oc-sess-reuse")).toBe(false);
844+});
845+846+it("ignores a persisted sdkSessionId when the compatibility fingerprint changes", async () => {
847+const sessionStore = makeSessionStoreMock();
848+mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
849+deps.onSessionEstablished?.({
850+sdkSessionId: "sdk-sess-old-model",
851+pooledClient: { key: {} as any, client: {} as any },
852+});
853+return ATTEMPT_RESULT;
854+});
855+const firstHarness = createCopilotAgentHarness({
856+pool: makePoolMock(),
857+sessionStore: sessionStore.store,
858+});
859+const secondHarness = createCopilotAgentHarness({
860+pool: makePoolMock(),
861+sessionStore: sessionStore.store,
862+});
863+864+await firstHarness.runAttempt(
865+makeAttemptParams({ runId: "t1", model: { provider: "github-copilot", id: "gpt-4.1" } }),
866+);
867+await secondHarness.runAttempt(
868+makeAttemptParams({
869+runId: "t2",
870+model: { provider: "github-copilot", id: "claude-sonnet-4.5" },
871+}),
872+);
873+874+const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as {
875+initialReplayState?: { sdkSessionId?: string };
876+};
877+expect(secondCallParams.initialReplayState?.sdkSessionId).toBeUndefined();
878+});
879+880+it("ignores a persisted sdkSessionId when the default Copilot home changes by agent id", async () => {
881+const sessionStore = makeSessionStoreMock();
882+mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
883+deps.onSessionEstablished?.({
884+sdkSessionId: "sdk-sess-main-home",
885+pooledClient: { key: {} as any, client: {} as any },
886+});
887+return ATTEMPT_RESULT;
888+});
889+const firstHarness = createCopilotAgentHarness({
890+pool: makePoolMock(),
891+sessionStore: sessionStore.store,
892+});
893+const secondHarness = createCopilotAgentHarness({
894+pool: makePoolMock(),
895+sessionStore: sessionStore.store,
896+});
897+const defaultHomeParams = {
898+agentDir: undefined,
899+copilotHome: undefined,
900+};
901+902+await firstHarness.runAttempt(
903+makeAttemptParams({
904+ ...defaultHomeParams,
905+runId: "t1",
906+agentId: "main",
907+}),
908+);
909+await secondHarness.runAttempt(
910+makeAttemptParams({
911+ ...defaultHomeParams,
912+runId: "t2",
913+agentId: "ops",
914+}),
915+);
916+917+const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as {
918+initialReplayState?: { sdkSessionId?: string };
919+};
920+expect(secondCallParams.initialReplayState?.sdkSessionId).toBeUndefined();
921+});
922+923+it("does not let stale plugin state override a newer incompatible tracked session", async () => {
924+const sessionStore = makeSessionStoreMock();
925+mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
926+deps.onSessionEstablished?.({
927+sdkSessionId: "sdk-sess-tracked-model",
928+pooledClient: { key: {} as any, client: {} as any },
929+});
930+return ATTEMPT_RESULT;
931+});
932+const harness = createCopilotAgentHarness({
933+pool: makePoolMock(),
934+sessionStore: sessionStore.store,
935+});
936+937+await harness.runAttempt(
938+makeAttemptParams({ runId: "t1", model: { provider: "github-copilot", id: "gpt-4.1" } }),
939+);
940+const persisted = sessionStore.entries.get("oc-sess-reuse");
941+expect(persisted).toBeDefined();
942+943+await harness.runAttempt(
944+makeAttemptParams({
945+runId: "t2",
946+model: { provider: "github-copilot", id: "claude-sonnet-4.5" },
947+}),
948+);
949+sessionStore.entries.set("oc-sess-reuse", persisted!);
950+await harness.runAttempt(
951+makeAttemptParams({ runId: "t3", model: { provider: "github-copilot", id: "gpt-4.1" } }),
952+);
953+954+const thirdCallParams = mocks.runCopilotAttempt.mock.calls[2]?.[0] as {
955+initialReplayState?: { sdkSessionId?: string };
956+};
957+expect(thirdCallParams.initialReplayState?.sdkSessionId).toBeUndefined();
958+});
959+960+it("deletes persisted sdkSessionId on reset even when no in-memory client is tracked", async () => {
961+const sessionStore = makeSessionStoreMock();
962+sessionStore.entries.set("oc-sess-reuse", {
963+schemaVersion: 1,
964+sdkSessionId: "sdk-sess-orphan",
965+compatKey: "compat",
966+updatedAt: 1,
967+});
968+const harness = createCopilotAgentHarness({
969+pool: makePoolMock(),
970+sessionStore: sessionStore.store,
971+});
972+973+await harness.reset?.({ sessionId: "oc-sess-reuse" });
974+975+expect(sessionStore.store.delete).toHaveBeenCalledWith("oc-sess-reuse");
976+expect(sessionStore.entries.has("oc-sess-reuse")).toBe(false);
977+});
978+979+it("still clears tracked SDK sessions when durable reset delete fails", async () => {
980+const sessionStore = makeSessionStoreMock();
981+sessionStore.store.delete.mockImplementation(() => {
982+throw new Error("sqlite delete failed");
983+});
984+const deleteSession = vi.fn();
985+mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
986+deps.onSessionEstablished?.({
987+sdkSessionId: "sdk-sess-reset",
988+pooledClient: { key: {} as any, client: { deleteSession } as any },
989+});
990+return ATTEMPT_RESULT;
991+});
992+const harness = createCopilotAgentHarness({
993+pool: makePoolMock(),
994+sessionStore: sessionStore.store,
995+});
996+997+await harness.runAttempt(makeAttemptParams({ runId: "t1" }));
998+await harness.reset?.({ sessionId: "oc-sess-reuse" });
999+1000+expect(deleteSession).toHaveBeenCalledWith("sdk-sess-reset");
1001+});
1002+1003+it("blocks persisted reuse after reset cannot delete a durable binding", async () => {
1004+const sessionStore = makeSessionStoreMock();
1005+mocks.runCopilotAttempt.mockImplementationOnce(async (_params, deps) => {
1006+deps.onSessionEstablished?.({
1007+sdkSessionId: "sdk-sess-before-reset",
1008+pooledClient: { key: {} as any, client: {} as any },
1009+});
1010+return ATTEMPT_RESULT;
1011+});
1012+const firstHarness = createCopilotAgentHarness({
1013+pool: makePoolMock(),
1014+sessionStore: sessionStore.store,
1015+});
1016+1017+await firstHarness.runAttempt(makeAttemptParams({ runId: "t1" }));
1018+expect(sessionStore.entries.get("oc-sess-reuse")?.sdkSessionId).toBe("sdk-sess-before-reset");
1019+sessionStore.store.delete.mockImplementation(() => {
1020+throw new Error("sqlite delete failed");
1021+});
1022+mocks.runCopilotAttempt.mockResolvedValue(ATTEMPT_RESULT);
1023+const harness = createCopilotAgentHarness({
1024+pool: makePoolMock(),
1025+sessionStore: sessionStore.store,
1026+});
1027+1028+await harness.reset?.({ sessionId: "oc-sess-reuse" });
1029+await harness.runAttempt(makeAttemptParams({ runId: "t2" }));
1030+1031+const callParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as {
1032+initialReplayState?: { sdkSessionId?: string };
1033+};
1034+expect(callParams.initialReplayState?.sdkSessionId).toBeUndefined();
1035+});
7391036});
74010377411038describe("compact", () => {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。