fix(slack): keep thread session fork/history context after first turn (#23843)

* Slack thread sessions: keep forking and history context after first turn

* Update CHANGELOG.md
This commit is contained in:
Vincent Koc
2026-02-22 14:39:00 -05:00
committed by GitHub
parent 02772b029d
commit 5e73f33448
8 changed files with 120 additions and 14 deletions

View File

@@ -169,6 +169,20 @@ describe("runPreparedReply media-only handling", () => {
expect(call?.followupRun.prompt).toContain("[User sent media without caption]");
});
it("keeps thread history context on follow-up turns", async () => {
const result = await runPreparedReply(
baseParams({
isNewSession: false,
}),
);
expect(result).toEqual({ text: "ok" });
const call = vi.mocked(runReplyAgent).mock.calls[0]?.[0];
expect(call).toBeTruthy();
expect(call?.followupRun.prompt).toContain("[Thread history - for context]");
expect(call?.followupRun.prompt).toContain("Earlier message in this thread");
});
it("returns the empty-body reply when there is no text and no media", async () => {
const result = await runPreparedReply(
baseParams({

View File

@@ -260,12 +260,11 @@ export async function runPreparedReply(
prefixedBodyBase = appendUntrustedContext(prefixedBodyBase, sessionCtx.UntrustedContext);
const threadStarterBody = ctx.ThreadStarterBody?.trim();
const threadHistoryBody = ctx.ThreadHistoryBody?.trim();
const threadContextNote =
isNewSession && threadHistoryBody
? `[Thread history - for context]\n${threadHistoryBody}`
: isNewSession && threadStarterBody
? `[Thread starter - for context]\n${threadStarterBody}`
: undefined;
const threadContextNote = threadHistoryBody
? `[Thread history - for context]\n${threadHistoryBody}`
: threadStarterBody
? `[Thread starter - for context]\n${threadStarterBody}`
: undefined;
const skillResult = await ensureSkillSnapshot({
sessionEntry,
sessionStore,

View File

@@ -126,6 +126,81 @@ describe("initSessionState thread forking", () => {
warn.mockRestore();
});
it("forks from parent when thread session key already exists but was not forked yet", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const root = await makeCaseDir("openclaw-thread-session-existing-");
const sessionsDir = path.join(root, "sessions");
await fs.mkdir(sessionsDir);
const parentSessionId = "parent-session";
const parentSessionFile = path.join(sessionsDir, "parent.jsonl");
const header = {
type: "session",
version: 3,
id: parentSessionId,
timestamp: new Date().toISOString(),
cwd: process.cwd(),
};
const message = {
type: "message",
id: "m1",
parentId: null,
timestamp: new Date().toISOString(),
message: { role: "user", content: "Parent prompt" },
};
await fs.writeFile(
parentSessionFile,
`${JSON.stringify(header)}\n${JSON.stringify(message)}\n`,
"utf-8",
);
const storePath = path.join(root, "sessions.json");
const parentSessionKey = "agent:main:slack:channel:c1";
const threadSessionKey = "agent:main:slack:channel:c1:thread:123";
await saveSessionStore(storePath, {
[parentSessionKey]: {
sessionId: parentSessionId,
sessionFile: parentSessionFile,
updatedAt: Date.now(),
},
[threadSessionKey]: {
sessionId: "preseed-thread-session",
updatedAt: Date.now(),
},
});
const cfg = {
session: { store: storePath },
} as OpenClawConfig;
const first = await initSessionState({
ctx: {
Body: "Thread reply",
SessionKey: threadSessionKey,
ParentSessionKey: parentSessionKey,
},
cfg,
commandAuthorized: true,
});
expect(first.sessionEntry.sessionId).not.toBe("preseed-thread-session");
expect(first.sessionEntry.forkedFromParent).toBe(true);
const second = await initSessionState({
ctx: {
Body: "Thread reply 2",
SessionKey: threadSessionKey,
ParentSessionKey: parentSessionKey,
},
cfg,
commandAuthorized: true,
});
expect(second.sessionEntry.sessionId).toBe(first.sessionEntry.sessionId);
expect(second.sessionEntry.forkedFromParent).toBe(true);
warn.mockRestore();
});
it("records topic-specific session files when MessageThreadId is present", async () => {
const root = await makeCaseDir("openclaw-topic-session-");
const storePath = path.join(root, "sessions.json");

View File

@@ -336,11 +336,12 @@ export async function initSessionState(params: {
sessionEntry.displayName = threadLabel;
}
const parentSessionKey = ctx.ParentSessionKey?.trim();
const alreadyForked = sessionEntry.forkedFromParent === true;
if (
isNewSession &&
parentSessionKey &&
parentSessionKey !== sessionKey &&
sessionStore[parentSessionKey]
sessionStore[parentSessionKey] &&
!alreadyForked
) {
log.warn(
`forking from parent session: parentKey=${parentSessionKey} → sessionKey=${sessionKey} ` +
@@ -355,6 +356,7 @@ export async function initSessionState(params: {
sessionId = forked.sessionId;
sessionEntry.sessionId = forked.sessionId;
sessionEntry.sessionFile = forked.sessionFile;
sessionEntry.forkedFromParent = true;
log.warn(`forked session created: file=${forked.sessionFile}`);
}
}