



























@@ -292,9 +292,14 @@ describe("slack prepareSlackMessage inbound contract", () => {
292292replies: ReturnType<typeof vi.fn>,
293293starterText: string,
294294followUpText: string,
295+options?: { expectStarterBody?: boolean },
295296) {
296297assertPrepared(prepared);
297-expect(prepared.ctxPayload.ThreadStarterBody).toBe(starterText);
298+if (options?.expectStarterBody === false) {
299+expect(prepared.ctxPayload.ThreadStarterBody).toBeUndefined();
300+} else {
301+expect(prepared.ctxPayload.ThreadStarterBody).toBe(starterText);
302+}
298303expect(prepared.ctxPayload.ThreadHistoryBody).toContain(starterText);
299304expect(prepared.ctxPayload.ThreadHistoryBody).toContain(followUpText);
300305expect(prepared.ctxPayload.ThreadHistoryBody).not.toContain("assistant reply");
@@ -1345,6 +1350,7 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
13451350replies,
13461351"starter from open dm",
13471352"dm follow-up",
1353+{ expectStarterBody: false },
13481354);
13491355});
13501356@@ -1502,7 +1508,7 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
15021508expect(prepared.ctxPayload.MessageThreadId).toBe("500.000");
15031509});
150415101505-it("records non-main DM last-route metadata on the prepared thread session", async () => {
1511+it("records non-main DM thread replies on the prepared direct session", async () => {
15061512const { storePath } = storeFixture.makeTmpStorePath();
15071513const slackCtx = createInboundSlackCtx({
15081514cfg: {
@@ -1525,7 +1531,13 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
1525153115261532assertPrepared(prepared);
15271533expect(prepared.route.sessionKey).toBe("agent:main:slack:direct:u1");
1528-expect(prepared.ctxPayload.SessionKey).toBe("agent:main:slack:direct:u1:thread:500.000");
1534+expect(prepared.ctxPayload.SessionKey).toBe("agent:main:slack:direct:u1");
1535+expect(prepared.ctxPayload.ParentSessionKey).toBeUndefined();
1536+expect(prepared.ctxPayload.MessageThreadId).toBeUndefined();
1537+expect(prepared.ctxPayload.ThreadLabel).toBeUndefined();
1538+expect(prepared.ctxPayload.IsFirstThreadTurn).toBeUndefined();
1539+expect(prepared.ctxPayload.ReplyToId).toBe("500.000");
1540+expect(prepared.ctxPayload.TransportThreadId).toBe("500.000");
15291541expect(
15301542(prepared.turn.record as { updateLastRoute?: { sessionKey?: string } }).updateLastRoute,
15311543).toEqual({
@@ -1538,7 +1550,7 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
15381550});
15391551});
154015521541-it("keeps default main-scope DM last-route metadata on the main session", async () => {
1553+it("keeps default main-scope DM thread replies on the main session", async () => {
15421554const slackCtx = createInboundSlackCtx({
15431555cfg: {
15441556channels: { slack: { enabled: true, replyToMode: "all" } },
@@ -1558,7 +1570,13 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
15581570);
1559157115601572assertPrepared(prepared);
1561-expect(prepared.ctxPayload.SessionKey).toBe("agent:main:main:thread:600.000");
1573+expect(prepared.ctxPayload.SessionKey).toBe("agent:main:main");
1574+expect(prepared.ctxPayload.ParentSessionKey).toBeUndefined();
1575+expect(prepared.ctxPayload.MessageThreadId).toBeUndefined();
1576+expect(prepared.ctxPayload.ThreadLabel).toBeUndefined();
1577+expect(prepared.ctxPayload.IsFirstThreadTurn).toBeUndefined();
1578+expect(prepared.ctxPayload.ReplyToId).toBe("600.000");
1579+expect(prepared.ctxPayload.TransportThreadId).toBe("600.000");
15621580expect(
15631581(prepared.turn.record as { updateLastRoute?: { sessionKey?: string } }).updateLastRoute,
15641582).toEqual({
@@ -1571,6 +1589,91 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
15711589});
15721590});
157315911592+it("preserves Slack thread history when an existing DM session receives a thread reply", async () => {
1593+const { storePath } = storeFixture.makeTmpStorePath();
1594+fs.writeFileSync(
1595+storePath,
1596+JSON.stringify(
1597+{
1598+"agent:main:main": { updatedAt: Date.now() },
1599+"agent:main:main:thread:650.000": { updatedAt: Date.now() },
1600+},
1601+null,
1602+2,
1603+),
1604+);
1605+const replies = vi
1606+.fn()
1607+.mockResolvedValueOnce({
1608+messages: [{ text: "starter topic", user: "U1", ts: "650.000" }],
1609+})
1610+.mockResolvedValueOnce({
1611+messages: [
1612+{ text: "starter topic", user: "U1", ts: "650.000" },
1613+{ text: "assistant reply", bot_id: "B1", ts: "650.500" },
1614+{ text: "user follow-up", user: "U1", ts: "650.800" },
1615+{ text: "current message", user: "U1", ts: "651.000" },
1616+],
1617+response_metadata: { next_cursor: "" },
1618+});
1619+const slackCtx = createInboundSlackCtx({
1620+cfg: {
1621+session: { store: storePath },
1622+channels: { slack: { enabled: true, replyToMode: "all" } },
1623+} as OpenClawConfig,
1624+appClient: { conversations: { replies } } as unknown as App["client"],
1625+replyToMode: "all",
1626+});
1627+slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
1628+1629+const prepared = await prepareMessageWith(
1630+slackCtx,
1631+createSlackAccount({ replyToMode: "all", thread: { initialHistoryLimit: 20 } }),
1632+createSlackMessage({
1633+text: "current message",
1634+ts: "651.000",
1635+thread_ts: "650.000",
1636+}),
1637+);
1638+1639+assertPrepared(prepared);
1640+expect(prepared.ctxPayload.SessionKey).toBe("agent:main:main");
1641+expect(prepared.ctxPayload.MessageThreadId).toBeUndefined();
1642+expect(prepared.ctxPayload.ThreadStarterBody).toBeUndefined();
1643+expect(prepared.ctxPayload.ThreadHistoryBody).toContain("starter topic");
1644+expect(prepared.ctxPayload.ThreadHistoryBody).toContain("user follow-up");
1645+expect(prepared.ctxPayload.ThreadHistoryBody).not.toContain("assistant reply");
1646+expect(prepared.ctxPayload.ThreadHistoryBody).not.toContain("current message");
1647+expect(replies).toHaveBeenCalledTimes(2);
1648+});
1649+1650+it("keeps transport thread metadata for DM parent_user_id replies with self thread_ts", async () => {
1651+const slackCtx = createInboundSlackCtx({
1652+cfg: {
1653+channels: { slack: { enabled: true, replyToMode: "all" } },
1654+} as OpenClawConfig,
1655+replyToMode: "all",
1656+});
1657+slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
1658+1659+const prepared = await prepareMessageWith(
1660+slackCtx,
1661+createSlackAccount({ replyToMode: "all" }),
1662+createSlackMessage({
1663+text: "thread reply",
1664+ts: "701.000",
1665+thread_ts: "701.000",
1666+parent_user_id: "B1",
1667+}),
1668+);
1669+1670+assertPrepared(prepared);
1671+expect(prepared.ctxPayload.SessionKey).toBe("agent:main:main");
1672+expect(prepared.ctxPayload.MessageThreadId).toBeUndefined();
1673+expect(prepared.ctxPayload.ReplyToId).toBe("701.000");
1674+expect(prepared.ctxPayload.TransportThreadId).toBe("701.000");
1675+});
1676+15741677it("routes Slack thread replies through runtime conversation bindings", async () => {
15751678const targetSessionKey = "agent:review:acp:session-67739";
15761679const binding: SessionBindingRecord = {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。