

























@@ -40,7 +40,13 @@ const mocks = vi.hoisted(() => ({
4040loadConfigReturn: {} as Record<string, unknown>,
4141loadVoiceWakeRoutingConfig: vi.fn(),
4242resolveVoiceWakeRouteByTrigger: vi.fn(),
43-resolveSendPolicy: vi.fn(() => "allow"),
43+resolveSendPolicy: vi.fn((_args?: { entry?: { sendPolicy?: string } }) => "allow"),
44+resolveSessionLifecycleTimestamps: vi.fn(
45+({ entry }: { entry?: { sessionStartedAt?: number; lastInteractionAt?: number } }) => ({
46+sessionStartedAt: entry?.sessionStartedAt,
47+lastInteractionAt: entry?.lastInteractionAt,
48+}),
49+),
4450}));
45514652vi.mock("../session-utils.js", async () => {
@@ -59,6 +65,7 @@ vi.mock("../../config/sessions.js", async () => {
5965return {
6066 ...actual,
6167updateSessionStore: mocks.updateSessionStore,
68+resolveSessionLifecycleTimestamps: mocks.resolveSessionLifecycleTimestamps,
6269resolveAgentIdFromSessionKey: (sessionKey: string) => {
6370const m = /^agent:([^:]+):/.exec(sessionKey.trim());
6471return m?.[1] ?? "main";
@@ -497,6 +504,14 @@ describe("gateway agent handler", () => {
497504mocks.resolveBareResetBootstrapFileAccess.mockReset().mockReturnValue(true);
498505mocks.listAgentIds.mockReset().mockReturnValue(["main"]);
499506mocks.resolveSendPolicy.mockReset().mockReturnValue("allow");
507+mocks.resolveSessionLifecycleTimestamps
508+.mockReset()
509+.mockImplementation(
510+({ entry }: { entry?: { sessionStartedAt?: number; lastInteractionAt?: number } }) => ({
511+sessionStartedAt: entry?.sessionStartedAt,
512+lastInteractionAt: entry?.lastInteractionAt,
513+}),
514+);
500515dateOnlyFakeClockActive = false;
501516vi.useRealTimers();
502517resetExecApprovalFollowupRuntimeHandoffsForTests();
@@ -1021,6 +1036,344 @@ describe("gateway agent handler", () => {
10211036expect(capturedEntry.cliSessionIds).toEqual(existingCliSessionIds);
10221037expect(capturedEntry.claudeCliSessionId).toBe(existingClaudeCliSessionId);
10231038});
1039+// #5369: sessions.patch can write modelOverride to the session store between
1040+// when the agent handler reads its cached entry and when updateSessionStore
1041+// runs. The handler's loadSessionEntry may return the stale pre-patch entry
1042+// (no modelOverride), while the store-load inside updateSessionStore has the
1043+// fresh value. If the patch built from the stale entry carries modelOverride:
1044+// undefined, the merge {...fresh, ...patch} clobbers the fresh value.
1045+it("preserves fresh modelOverride when cached entry is stale (#5369)", async () => {
1046+mocks.loadSessionEntry.mockReturnValue({
1047+cfg: {},
1048+storePath: "/tmp/sessions.json",
1049+entry: {
1050+sessionId: "subagent-session-id",
1051+updatedAt: Date.now() - 1000,
1052+// modelOverride absent — stale pre-patch view
1053+},
1054+canonicalKey: "agent:main:subagent:test-uuid",
1055+});
1056+let capturedEntry: Record<string, unknown> | undefined;
1057+mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
1058+const freshStore: Record<string, Record<string, unknown>> = {
1059+"agent:main:subagent:test-uuid": {
1060+sessionId: "subagent-session-id",
1061+updatedAt: Date.now(),
1062+modelOverride: "qwen3-coder:30b",
1063+providerOverride: "ollama",
1064+},
1065+};
1066+const result = await updater(freshStore);
1067+capturedEntry = freshStore["agent:main:subagent:test-uuid"];
1068+return result;
1069+});
1070+mocks.agentCommand.mockResolvedValue({
1071+payloads: [{ text: "ok" }],
1072+meta: { durationMs: 100 },
1073+});
1074+await invokeAgent(
1075+{
1076+message: "hi",
1077+agentId: "main",
1078+sessionKey: "agent:main:subagent:test-uuid",
1079+idempotencyKey: "test-5369-race",
1080+},
1081+{ reqId: "race-1" },
1082+);
1083+expect(capturedEntry?.modelOverride).toBe("qwen3-coder:30b");
1084+expect(capturedEntry?.providerOverride).toBe("ollama");
1085+});
1086+// Broader regression guard for the #5369 stale-writeback class: any field
1087+// that the patch blindly carries from the cached entry will clobber a fresh
1088+// concurrent write. The fix dropped all such fields from the patch; this
1089+// test ensures none get silently re-added. If a future change puts e.g.
1090+// `sendPolicy: entry?.sendPolicy` back into the patch, this test fails.
1091+it("preserves all fresh session fields when cached entry is stale (#5369 broader)", async () => {
1092+mocks.loadSessionEntry.mockReturnValue({
1093+cfg: {},
1094+storePath: "/tmp/sessions.json",
1095+entry: {
1096+sessionId: "subagent-session-id",
1097+updatedAt: Date.now() - 1000,
1098+// All fields below absent — stale pre-patch view
1099+},
1100+canonicalKey: "agent:main:subagent:test-broader",
1101+});
1102+const freshFields = {
1103+sendPolicy: "allow",
1104+skillsSnapshot: { tools: ["bash"] },
1105+thinkingLevel: "high",
1106+fastMode: true,
1107+verboseLevel: "detailed",
1108+traceLevel: "info",
1109+reasoningLevel: "on",
1110+systemSent: true,
1111+spawnedWorkspaceDir: "/work/fresh",
1112+spawnDepth: 2,
1113+label: "fresh-label",
1114+spawnedBy: "agent:main:main",
1115+channel: "telegram",
1116+deliveryContext: {
1117+channel: "telegram",
1118+to: "12345",
1119+accountId: "acct-1",
1120+threadId: 42,
1121+},
1122+lastChannel: "telegram",
1123+lastTo: "12345",
1124+lastAccountId: "acct-1",
1125+lastThreadId: 42,
1126+cliSessionIds: { "claude-cli": "fresh-cli-id" },
1127+cliSessionBindings: { "claude-cli": { sessionId: "fresh-binding" } },
1128+claudeCliSessionId: "fresh-cli-id",
1129+};
1130+let capturedEntry: Record<string, unknown> | undefined;
1131+mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
1132+const freshStore: Record<string, Record<string, unknown>> = {
1133+"agent:main:subagent:test-broader": {
1134+sessionId: "subagent-session-id",
1135+updatedAt: Date.now(),
1136+ ...freshFields,
1137+},
1138+};
1139+const result = await updater(freshStore);
1140+capturedEntry = freshStore["agent:main:subagent:test-broader"];
1141+return result;
1142+});
1143+mocks.agentCommand.mockResolvedValue({
1144+payloads: [{ text: "ok" }],
1145+meta: { durationMs: 100 },
1146+});
1147+await invokeAgent(
1148+{
1149+message: "hi",
1150+agentId: "main",
1151+sessionKey: "agent:main:subagent:test-broader",
1152+idempotencyKey: "test-5369-broader",
1153+},
1154+{ reqId: "broader-1" },
1155+);
1156+for (const [field, expected] of Object.entries(freshFields)) {
1157+expect(capturedEntry?.[field]).toEqual(expected);
1158+}
1159+});
1160+it("checks delivery sendPolicy against the fresh store entry (#5369)", async () => {
1161+mocks.loadSessionEntry.mockReturnValue({
1162+cfg: {},
1163+storePath: "/tmp/sessions.json",
1164+entry: {
1165+sessionId: "subagent-session-id",
1166+updatedAt: Date.now() - 1000,
1167+// sendPolicy absent — stale pre-patch view
1168+},
1169+canonicalKey: "agent:main:subagent:test-policy",
1170+});
1171+const freshUpdatedAt = Date.now();
1172+let capturedEntry: Record<string, unknown> | undefined;
1173+mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
1174+const freshStore: Record<string, Record<string, unknown>> = {
1175+"agent:main:subagent:test-policy": {
1176+sessionId: "subagent-session-id",
1177+updatedAt: freshUpdatedAt,
1178+sendPolicy: "deny",
1179+channel: "telegram",
1180+},
1181+};
1182+const result = await updater(freshStore);
1183+capturedEntry = freshStore["agent:main:subagent:test-policy"];
1184+return result;
1185+});
1186+mocks.resolveSendPolicy.mockImplementation((args?: { entry?: { sendPolicy?: string } }) =>
1187+args?.entry?.sendPolicy === "deny" ? "deny" : "allow",
1188+);
1189+mocks.agentCommand.mockClear();
1190+mocks.agentCommand.mockResolvedValue({
1191+payloads: [{ text: "ok" }],
1192+meta: { durationMs: 100 },
1193+});
1194+const respond = vi.fn();
1195+await invokeAgent(
1196+{
1197+message: "hi",
1198+agentId: "main",
1199+sessionKey: "agent:main:subagent:test-policy",
1200+channel: "telegram",
1201+to: "99999",
1202+deliver: true,
1203+idempotencyKey: "test-5369-policy",
1204+},
1205+{ reqId: "policy-1", respond },
1206+);
1207+expectRespondError(respond, { message: "send blocked by session policy" });
1208+const sendPolicyArgs = expectRecordFields(mockCallArg(mocks.resolveSendPolicy), {
1209+sessionKey: "agent:main:subagent:test-policy",
1210+});
1211+expectRecordFields(sendPolicyArgs.entry, { sendPolicy: "deny" });
1212+expectRecordFields(capturedEntry, {
1213+sessionId: "subagent-session-id",
1214+updatedAt: freshUpdatedAt,
1215+sendPolicy: "deny",
1216+channel: "telegram",
1217+deliveryContext: undefined,
1218+lastTo: undefined,
1219+});
1220+expect(mocks.agentCommand).not.toHaveBeenCalled();
1221+});
1222+it("does not restore a stale session id over a fresh store rotation (#5369)", async () => {
1223+mocks.resolveSessionLifecycleTimestamps.mockImplementation(
1224+({ entry }: { entry?: { sessionId?: string; sessionStartedAt?: number } }) => ({
1225+sessionStartedAt: entry?.sessionId === "old-session-id" ? 123 : entry?.sessionStartedAt,
1226+lastInteractionAt: undefined,
1227+}),
1228+);
1229+mocks.loadSessionEntry.mockReturnValue({
1230+cfg: {},
1231+storePath: "/tmp/sessions.json",
1232+entry: {
1233+sessionId: "old-session-id",
1234+updatedAt: Date.now() - 1000,
1235+},
1236+canonicalKey: "agent:main:subagent:test-rotation",
1237+});
1238+let capturedEntry: Record<string, unknown> | undefined;
1239+mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
1240+const freshStore: Record<string, Record<string, unknown>> = {
1241+"agent:main:subagent:test-rotation": {
1242+sessionId: "fresh-session-id",
1243+updatedAt: Date.now(),
1244+status: "running",
1245+startedAt: 111,
1246+sessionFile: "/tmp/fresh-session.jsonl",
1247+},
1248+};
1249+const result = await updater(freshStore);
1250+capturedEntry = freshStore["agent:main:subagent:test-rotation"];
1251+return result;
1252+});
1253+mocks.agentCommand.mockResolvedValue({
1254+payloads: [{ text: "ok" }],
1255+meta: { durationMs: 100 },
1256+});
1257+1258+await invokeAgent(
1259+{
1260+message: "hi",
1261+agentId: "main",
1262+sessionKey: "agent:main:subagent:test-rotation",
1263+idempotencyKey: "test-5369-rotation",
1264+},
1265+{ reqId: "rotation-1" },
1266+);
1267+1268+expectRecordFields(capturedEntry, {
1269+sessionId: "fresh-session-id",
1270+status: "running",
1271+startedAt: 111,
1272+sessionStartedAt: undefined,
1273+sessionFile: "/tmp/fresh-session.jsonl",
1274+});
1275+});
1276+// Upgrade-path self-heal: a legacy session entry may lack sessionStartedAt
1277+// because the field was added after the entry was first persisted. The
1278+// handler recovers it from the transcript JSONL header and writes it back,
1279+// but only when the fresh store still lacks the field — so a concurrent
1280+// writer that sets it cannot be clobbered (the #5369 stale-writeback class).
1281+it("self-heals missing sessionStartedAt from JSONL when fresh store also lacks it", async () => {
1282+// Use a value distinct from `now` but recent enough that
1283+// evaluateSessionFreshness — which also calls the mocked
1284+// resolveSessionLifecycleTimestamps — keeps this session fresh.
1285+const recoveredStartedAt = Date.now() - 5_000;
1286+mocks.loadSessionEntry.mockReturnValue({
1287+cfg: {},
1288+storePath: "/tmp/sessions.json",
1289+entry: {
1290+sessionId: "legacy-session-id",
1291+updatedAt: Date.now() - 1000,
1292+// sessionStartedAt absent — legacy schema
1293+},
1294+canonicalKey: "agent:main:subagent:legacy",
1295+});
1296+mocks.resolveSessionLifecycleTimestamps.mockReturnValue({
1297+sessionStartedAt: recoveredStartedAt,
1298+lastInteractionAt: undefined,
1299+});
1300+let capturedEntry: Record<string, unknown> | undefined;
1301+mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
1302+const freshStore: Record<string, Record<string, unknown>> = {
1303+"agent:main:subagent:legacy": {
1304+sessionId: "legacy-session-id",
1305+updatedAt: Date.now(),
1306+// sessionStartedAt absent on disk too — self-heal should fire
1307+},
1308+};
1309+const result = await updater(freshStore);
1310+capturedEntry = freshStore["agent:main:subagent:legacy"];
1311+return result;
1312+});
1313+mocks.agentCommand.mockResolvedValue({
1314+payloads: [{ text: "ok" }],
1315+meta: { durationMs: 100 },
1316+});
1317+await invokeAgent(
1318+{
1319+message: "hi",
1320+agentId: "main",
1321+sessionKey: "agent:main:subagent:legacy",
1322+idempotencyKey: "test-selfheal-write",
1323+},
1324+{ reqId: "selfheal-1" },
1325+);
1326+expect(capturedEntry?.sessionStartedAt).toBe(recoveredStartedAt);
1327+});
1328+it("does not clobber fresh sessionStartedAt with the recovered candidate", async () => {
1329+// See note in the prior test: keep both values recent so freshness
1330+// evaluation (which also reads the lifecycle mock) doesn't trip the
1331+// idle-reset path and turn this into an isNewSession path.
1332+const recoveredStartedAt = Date.now() - 5_000;
1333+const freshStartedAt = Date.now() - 2_500;
1334+mocks.loadSessionEntry.mockReturnValue({
1335+cfg: {},
1336+storePath: "/tmp/sessions.json",
1337+entry: {
1338+sessionId: "legacy-session-id",
1339+updatedAt: Date.now() - 1000,
1340+// sessionStartedAt absent in cached entry — would trigger recovery
1341+},
1342+canonicalKey: "agent:main:subagent:concurrent",
1343+});
1344+mocks.resolveSessionLifecycleTimestamps.mockReturnValue({
1345+sessionStartedAt: recoveredStartedAt,
1346+lastInteractionAt: undefined,
1347+});
1348+let capturedEntry: Record<string, unknown> | undefined;
1349+mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
1350+const freshStore: Record<string, Record<string, unknown>> = {
1351+"agent:main:subagent:concurrent": {
1352+sessionId: "legacy-session-id",
1353+updatedAt: Date.now(),
1354+// Concurrent writer set sessionStartedAt between cache load and lock
1355+sessionStartedAt: freshStartedAt,
1356+},
1357+};
1358+const result = await updater(freshStore);
1359+capturedEntry = freshStore["agent:main:subagent:concurrent"];
1360+return result;
1361+});
1362+mocks.agentCommand.mockResolvedValue({
1363+payloads: [{ text: "ok" }],
1364+meta: { durationMs: 100 },
1365+});
1366+await invokeAgent(
1367+{
1368+message: "hi",
1369+agentId: "main",
1370+sessionKey: "agent:main:subagent:concurrent",
1371+idempotencyKey: "test-selfheal-noclobber",
1372+},
1373+{ reqId: "selfheal-2" },
1374+);
1375+expect(capturedEntry?.sessionStartedAt).toBe(freshStartedAt);
1376+});
10241377it("reactivates completed subagent sessions and broadcasts send updates", async () => {
10251378const childSessionKey = "agent:main:subagent:followup";
10261379const completedRun = {
@@ -3471,7 +3824,7 @@ describe("gateway agent handler", () => {
34713824let capturedEntry: Record<string, unknown> | undefined;
34723825mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
34733826const store: Record<string, unknown> = {
3474-[sessionKey]: { sessionId: "existing-session-id" },
3827+[sessionKey]: { sessionId: "existing-session-id", ...entry },
34753828};
34763829await updater(store);
34773830capturedEntry = store[sessionKey] as Record<string, unknown>;
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。