fix: canonicalize topic session transcript fallback (#64869)

* fix: canonicalize topic session transcript fallback

When initSessionState has a topic-scoped SessionKey but no MessageThreadId, fallback transcript selection should still land on the topic-qualified JSONL path instead of the bare session file. Match the existing transcript resolver by parsing the thread id from the session key, and cover the regression with a session init test that loads the Telegram session-conversation grammar.

Regeneration-Prompt: |
  Investigate why a Telegram topic session could alternate between <session-id>.jsonl and <session-id>-topic-<n>.jsonl for the same logical session. The fix should be in OpenClaw's session initialization path, not in lossless-claw. Keep behavior unchanged when MessageThreadId is present, but when the inbound turn only carries a topic-scoped SessionKey, derive the same topic-specific transcript path that the canonical transcript resolver would use. Add a regression test that proves initSessionState chooses the topic-qualified file even without MessageThreadId, and make the test load the session-conversation registry needed to parse Telegram :topic: grammar.

* fix: preserve topic session transcript history
This commit is contained in:
Josh Lehman
2026-04-11 09:06:49 -07:00
committed by GitHub
parent b66b8562eb
commit 77a0ee7f9d
3 changed files with 42 additions and 2 deletions

View File

@@ -8,6 +8,8 @@ Docs: https://docs.openclaw.ai
### Fixes
- Telegram/sessions: keep topic-scoped session initialization on the canonical topic transcript path when inbound turns omit `MessageThreadId`, so one topic session no longer alternates between bare and topic-qualified transcript files. (#64869) thanks @jalehman.
## 2026.4.11-beta.1
### Changes

View File

@@ -16,11 +16,12 @@ import {
registerSessionBindingAdapter,
} from "../../infra/outbound/session-binding-service.js";
import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { createSessionConversationTestRegistry } from "../../test-utils/session-conversation-registry.js";
import { drainFormattedSystemEvents } from "./session-updates.js";
import { persistSessionUsageUpdate } from "./session-usage.js";
import { initSessionState } from "./session.js";
@@ -561,6 +562,35 @@ describe("initSessionState thread forking", () => {
`${result.sessionEntry.sessionId}-topic-456.jsonl`,
);
});
it("records topic-specific session files from SessionKey when MessageThreadId is absent", async () => {
const root = await makeCaseDir("openclaw-topic-session-key-");
const storePath = path.join(root, "sessions.json");
const cfg = {
session: { store: storePath },
} as OpenClawConfig;
setActivePluginRegistry(createSessionConversationTestRegistry());
try {
const result = await initSessionState({
ctx: {
Body: "Hello topic",
SessionKey: "agent:main:telegram:group:123:topic:456",
},
cfg,
commandAuthorized: true,
});
const sessionFile = result.sessionEntry.sessionFile;
expect(sessionFile).toBeTruthy();
expect(path.basename(sessionFile ?? "")).toBe(
`${result.sessionEntry.sessionId}-topic-456.jsonl`,
);
} finally {
resetPluginRuntimeStateForTest();
}
});
});
describe("initSessionState RawBody", () => {

View File

@@ -21,6 +21,7 @@ import { resolveAndPersistSessionFile } from "../../config/sessions/session-file
import { resolveSessionKey } from "../../config/sessions/session-key.js";
import { resolveMaintenanceConfigFromInput } from "../../config/sessions/store-maintenance.js";
import { loadSessionStore, updateSessionStore } from "../../config/sessions/store.js";
import { parseSessionThreadInfo } from "../../config/sessions/thread-info.js";
import {
DEFAULT_RESET_TRIGGERS,
type GroupKeyResolution,
@@ -620,8 +621,15 @@ export async function initSessionState(params: {
}
}
}
const threadIdFromSessionKey = parseSessionThreadInfo(
sessionCtxForState.SessionKey ?? sessionKey,
).threadId;
const fallbackSessionFile = !sessionEntry.sessionFile
? resolveSessionTranscriptPath(sessionEntry.sessionId, agentId, ctx.MessageThreadId)
? resolveSessionTranscriptPath(
sessionEntry.sessionId,
agentId,
ctx.MessageThreadId ?? threadIdFromSessionKey,
)
: undefined;
const resolvedSessionFile = await resolveAndPersistSessionFile({
sessionId: sessionEntry.sessionId,