From 8fa4fad3a7bbfef0fb91c14f12c53a42c0ba2268 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 27 May 2026 05:27:57 +0100 Subject: [PATCH] perf(gateway): skip duplicate turn session touch --- .../agent-command.live-model-switch.test.ts | 61 ++++++++++++++++ src/agents/agent-command.ts | 10 ++- src/agents/command/types.ts | 2 + src/gateway/server-methods/agent.ts | 69 ++++++++++--------- 4 files changed, 110 insertions(+), 32 deletions(-) diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index 06dfa872f96..6ff18c39e22 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -741,6 +741,17 @@ async function runBasicAgentCommand() { }); } +function setupSessionTouchStore(): void { + const sessionEntry: SessionEntry = { + sessionId: "session-1", + updatedAt: 1, + skillsSnapshot: { prompt: "", skills: [], version: 0 }, + }; + state.sessionEntryMock = sessionEntry; + state.sessionStoreMock = { "agent:main:main": sessionEntry }; + state.storePathMock = "/tmp/openclaw-sessions.json"; +} + function expectFallbackOverrideCalls(first: boolean, second: boolean) { expect(state.resolveEffectiveModelFallbacksMock).toHaveBeenCalledTimes(2); expectRecordFields(mockCallArg(state.resolveEffectiveModelFallbacksMock, 0), { @@ -881,6 +892,56 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { ); }); + it("keeps the initial session touch for local runs", async () => { + setupSingleAttemptFallback(); + state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4")); + setupSessionTouchStore(); + + await runBasicAgentCommand(); + + const touchWrites = state.persistSessionEntryMock.mock.calls.filter((call) => { + const entry = (call[0] as { entry?: Record } | undefined)?.entry; + return entry?.lastInteractionAt !== undefined; + }); + expect(touchWrites).toHaveLength(1); + expect(state.updateSessionStoreAfterAgentRunMock).toHaveBeenCalledTimes(1); + }); + + it("skips the initial session touch after gateway ingress already persisted activity", async () => { + setupSingleAttemptFallback(); + state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4")); + setupSessionTouchStore(); + + await agentCommand({ + message: "hello", + to: "+1234567890", + skipInitialSessionTouch: true, + }); + + expect(state.persistSessionEntryMock).not.toHaveBeenCalled(); + expect(state.updateSessionStoreAfterAgentRunMock).toHaveBeenCalledTimes(1); + }); + + it("persists explicit overrides even when ingress skips the initial touch", async () => { + setupSingleAttemptFallback(); + state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4")); + setupSessionTouchStore(); + + await agentCommand({ + message: "hello", + to: "+1234567890", + thinking: "medium", + skipInitialSessionTouch: true, + }); + + const touchWrite = mockCallArg(state.persistSessionEntryMock) as { + entry?: Record; + }; + expect(touchWrite.entry?.thinkingLevel).toBe("medium"); + expect(touchWrite.entry?.lastInteractionAt).toBeDefined(); + expect(state.updateSessionStoreAfterAgentRunMock).toHaveBeenCalledTimes(1); + }); + it("clears stale flag-only pending final delivery when there is no final payload", async () => { setupSingleAttemptFallback(); state.runAgentAttemptMock.mockResolvedValue(makeEmptyResult("openai", "gpt-5.4")); diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 2dcbe1d0a85..914055779a1 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -865,7 +865,15 @@ async function agentCommandInternal( } // Persist explicit /command overrides to the session store when we have a key. - if (sessionStore && sessionKey && !suppressVisibleSessionEffects) { + const hasInitialSessionOverrides = Boolean(thinkOverride || verboseOverride); + const shouldPersistInitialSessionTouch = + opts.skipInitialSessionTouch !== true || hasInitialSessionOverrides; + if ( + sessionStore && + sessionKey && + !suppressVisibleSessionEffects && + shouldPersistInitialSessionTouch + ) { const now = Date.now(); const entry = sessionStore[sessionKey] ?? sessionEntry ?? { sessionId, updatedAt: now, sessionStartedAt: now }; diff --git a/src/agents/command/types.ts b/src/agents/command/types.ts index a299c081240..fb14dfba2e4 100644 --- a/src/agents/command/types.ts +++ b/src/agents/command/types.ts @@ -114,6 +114,8 @@ export type AgentCommandOpts = { sourceReplyDeliveryMode?: SourceReplyDeliveryMode; /** Internal runs can omit the channel message tool entirely. */ disableMessageTool?: boolean; + /** Gateway ingress that already persisted visible activity can skip the duplicate pre-run touch. */ + skipInitialSessionTouch?: boolean; /** Per-call stream param overrides (best-effort). */ streamParams?: AgentStreamParams; /** Explicit workspace directory override (for subagents to inherit parent workspace). */ diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 86089ef579a..7e33966960c 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -1230,6 +1230,7 @@ export const agentHandlers: GatewayRequestHandlers = { let isNewSession = false; let skipTimestampInjection = false; let shouldPrependStartupContext = false; + let skipAgentInitialSessionTouch = false; const resetCommandMatch = message.match(RESET_COMMAND_RE); if (resetCommandMatch && requestedSessionKey) { @@ -1548,42 +1549,47 @@ export const agentHandlers: GatewayRequestHandlers = { if (storePath && !suppressVisibleSessionEffects) { const requestedStoreKey = requestedSessionKey; let deniedBySendPolicy = false; - const persisted = await updateSessionStore(storePath, (store) => { - const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({ - cfg, - key: requestedStoreKey, - store, - }); - const freshEntry = store[primaryKey]; - patchBuild = buildSessionPatch(freshEntry); - const effectivePatch = - recoveredSessionStartedAt !== undefined && - freshEntry?.sessionStartedAt === undefined && - freshEntry?.sessionId === entry?.sessionId - ? { ...patchBuild.patch, sessionStartedAt: recoveredSessionStartedAt } - : patchBuild.patch; - const merged = mergeSessionEntry(freshEntry, effectivePatch); - const sendPolicy = - request.deliver === true - ? resolveSendPolicy({ - cfg, - entry: merged, - sessionKey: canonicalKey, - channel: merged?.channel, - chatType: merged?.chatType, - }) - : "allow"; - if (sendPolicy === "deny") { - deniedBySendPolicy = true; + const persisted = await updateSessionStore( + storePath, + (store) => { + const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({ + cfg, + key: requestedStoreKey, + store, + }); + const freshEntry = store[primaryKey]; + patchBuild = buildSessionPatch(freshEntry); + const effectivePatch = + recoveredSessionStartedAt !== undefined && + freshEntry?.sessionStartedAt === undefined && + freshEntry?.sessionId === entry?.sessionId + ? { ...patchBuild.patch, sessionStartedAt: recoveredSessionStartedAt } + : patchBuild.patch; + const merged = mergeSessionEntry(freshEntry, effectivePatch); + const sendPolicy = + request.deliver === true + ? resolveSendPolicy({ + cfg, + entry: merged, + sessionKey: canonicalKey, + channel: merged?.channel, + chatType: merged?.chatType, + }) + : "allow"; + if (sendPolicy === "deny") { + deniedBySendPolicy = true; + return merged; + } + store[primaryKey] = merged; return merged; - } - store[primaryKey] = merged; - return merged; - }); + }, + { takeCacheOwnership: true }, + ); if (persisted) { sessionEntry = persisted; resolvedSessionId = sessionEntry.sessionId; } + skipAgentInitialSessionTouch = touchInteraction; if (deniedBySendPolicy) { respond( false, @@ -2056,6 +2062,7 @@ export const agentHandlers: GatewayRequestHandlers = { internalEvents: request.internalEvents, inputProvenance, sessionEffects, + skipInitialSessionTouch: skipAgentInitialSessionTouch, preserveUserFacingSessionModelState, sourceReplyDeliveryMode: request.sourceReplyDeliveryMode, disableMessageTool: request.disableMessageTool,