Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 43 additions & 4 deletions packages/core/kernel/request-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import {
createActiveRequest,
failActiveRequest,
getPendingQuestion,
loadSession,
saveSession,
setPendingQuestion,
updateActiveRequest,
type ActiveRequest,
type PendingQuestion,
type PersistedSession,
type TrackedTodo,
type TrackedTool,
Expand All @@ -27,6 +29,7 @@ import { maybeGenerateSessionTitle } from "@/core/runtime/session-title";
import type { AgentAdapter, IMAdapter, StatusStreamChunk } from "@/core/types";
import type { RuntimeRequestContext } from "@/core/kernel/request-context";
import { formatSingleQuestionPrompt } from "@/core/runtime/helpers";
import { isSyntheticOwner } from "@/ims/shared/synthetic-owner";
import {
buildSessionMessageState,
createStatusStreamDiffer,
Expand Down Expand Up @@ -56,6 +59,34 @@ function isStatusStreamingEnabled(
return getSlackStatusModeForChannel(channelId) !== "legacy";
}

function mirrorPendingQuestionToRealThread(params: {
channelId: string;
syntheticThreadId: string;
realThreadId: string | undefined;
pendingQuestion: PendingQuestion;
}): void {
const { channelId, syntheticThreadId, realThreadId, pendingQuestion } = params;
if (!realThreadId || realThreadId === syntheticThreadId || !isSyntheticOwner(syntheticThreadId)) {
return;
}

const syntheticSession = loadSession(channelId, syntheticThreadId);
if (!syntheticSession) return;

const existingRealSession = loadSession(channelId, realThreadId);
if (existingRealSession) {
existingRealSession.pendingQuestion = pendingQuestion;
saveSession(existingRealSession);
return;
}

saveSession({
...syntheticSession,
threadId: realThreadId,
pendingQuestion,
});
Comment on lines +83 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid copying activeRequest into mirrored question sessions

When a synthetic task/cron question is mirrored to the real Slack question thread, this spread also copies the synthetic session's activeRequest into the real-thread session. The active request lifecycle still completes/fails only the original synthetic key (for example completeActiveRequest(context.channelId, context.threadId) in request-run.ts), so after the user answers and the run finishes the real-thread copy remains state: "processing". If the daemon restarts or the user sends stop in that real thread, recovery/stop handling will treat the already-finished run as pending and update/delete the stale status message or abort the session; the mirror should omit activeRequest and only seed the routing/session fields plus pendingQuestion.

Useful? React with 👍 / 👎.

}

/**
* Guard against publishing the user's own prompt as the bot's final reply.
*
Expand Down Expand Up @@ -568,11 +599,12 @@ async function startKernelEventStreamWatcher(params: {
}

void (async () => {
let questionMessageTs: string | undefined;
try {
const first = normalized[0]!;
const prefix = normalized.length > 1 ? `(1/${normalized.length}) ` : "";
if (typeof deps.im.sendQuestion === "function") {
await deps.im.sendQuestion(
questionMessageTs = await deps.im.sendQuestion(
request.channelId,
request.replyThreadId,
first.question,
Expand All @@ -581,7 +613,7 @@ async function startKernelEventStreamWatcher(params: {
);
} else {
const promptText = formatSingleQuestionPrompt(first, 0, normalized.length);
await deps.im.sendMessage(request.channelId, request.replyThreadId, promptText);
questionMessageTs = await deps.im.sendMessage(request.channelId, request.replyThreadId, promptText);
}
} catch (err) {
log.warn("Failed to post ask_user question", {
Expand All @@ -592,14 +624,21 @@ async function startKernelEventStreamWatcher(params: {
});
}

setPendingQuestion(request.channelId, request.threadId, {
const pendingQuestion: PendingQuestion = {
requestId,
sessionId: properties.sessionID ?? request.sessionId,
askedAt: Date.now(),
questions: normalized,
messageTs: request.statusMessageTs,
messageTs: questionMessageTs ?? request.statusMessageTs,
collectedAnswers: [],
questionDetailId,
};
setPendingQuestion(request.channelId, request.threadId, pendingQuestion);
mirrorPendingQuestionToRealThread({
channelId: request.channelId,
syntheticThreadId: request.threadId,
realThreadId: questionMessageTs,
pendingQuestion,
});
})();
return;
Expand Down
82 changes: 81 additions & 1 deletion packages/ims/slack/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,39 @@
import { beforeEach, describe, expect, it, mock } from "bun:test";
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";

// Both test groups below import `./api`, which imports `./client`. We mock
// the single `./client` module so we don't need a real Slack connection,
// and expose both the raw `apiCall` surface (used by the streaming helpers)
// and the typed `chat.postMessage` surface (used by `postSlackQuestion`).
//
// IMPORTANT: `mock.module(...)` is process-wide in Bun, so the stub stays in
// place for every later test file that loads `./client`. To avoid breaking
// downstream tests that pull other exports (e.g. `sendChannelMessage` used by
// `packages/core/tasks/scheduler.ts` and `packages/core/test/web-routes.test.ts`),
// we start from the real module's exports and only override `getApp` /
// `getSlackBotToken`. This way later imports keep finding every real export.
const apiCalls: Array<{ method: string; args: Record<string, unknown> }> = [];
const postMessageCalls: Array<Record<string, unknown>> = [];

const realClient = await import("./client");

mock.module("./client", () => ({
...realClient,
getApp: () => ({
client: {
apiCall: async (method: string, args: Record<string, unknown>) => {
apiCalls.push({ method, args });
return method === "chat.startStream" ? { ts: "111.222" } : {};
},
chat: {
postMessage: async (args: Record<string, unknown>) => {
postMessageCalls.push(args);
return { ok: true, ts: "1700000000.000100" };
},
},
},
}),
// Override so the test never accidentally picks up a stale workspace token
// during the test run.
getSlackBotToken: () => "xoxb-test",
}));

Expand Down Expand Up @@ -67,3 +90,60 @@ describe("Slack streaming API helpers", () => {
});
});
});

describe("postSlackQuestion thread_ts handling", () => {
afterEach(() => {
postMessageCalls.length = 0;
});

it("omits thread_ts when the thread id is a synthetic cron-job placeholder", async () => {
const { postSlackQuestion } = await import("./api");
await postSlackQuestion({
channelId: "C0ATGCJ0YK0",
threadId: "cron-job:a86fbdc5-01df-4caf-9e0c-c0c199f00379:1780441200000",
question: "Ready to deploy?",
options: ["Yes", "No"],
token: "xoxb-test",
});

expect(postMessageCalls.length).toBeGreaterThan(0);
for (const call of postMessageCalls) {
// Slack rejects synthetic placeholders with `invalid_thread_ts`; the
// adapter must drop `thread_ts` and fall back to a top-level post.
expect(call).not.toHaveProperty("thread_ts");
expect(call.channel).toBe("C0ATGCJ0YK0");
}
});

it("omits thread_ts for synthetic task placeholders too", async () => {
const { postSlackQuestion } = await import("./api");
await postSlackQuestion({
channelId: "C0ATGCJ0YK0",
threadId: "task:abc-123",
question: "Continue?",
token: "xoxb-test",
});

expect(postMessageCalls.length).toBeGreaterThan(0);
for (const call of postMessageCalls) {
expect(call).not.toHaveProperty("thread_ts");
}
});

it("passes thread_ts through for real Slack timestamps", async () => {
const { postSlackQuestion } = await import("./api");
const realTs = "1717000000.000200";
await postSlackQuestion({
channelId: "C0ATGCJ0YK0",
threadId: realTs,
question: "Ready to deploy?",
options: ["Yes", "No"],
token: "xoxb-test",
});

expect(postMessageCalls.length).toBeGreaterThan(0);
for (const call of postMessageCalls) {
expect(call.thread_ts).toBe(realTs);
}
});
});
12 changes: 10 additions & 2 deletions packages/ims/slack/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { basename } from "path";
import { getApp, getSlackBotToken } from "./client";
import { hasSimpleOptions } from "@/core/runtime/helpers";
import type { StatusStreamChunk } from "@/core/types";
import { isSyntheticOwner } from "@/ims/shared/synthetic-owner";

// ---------------------------------------------------------------------------
// Slack IM helper module.
Expand Down Expand Up @@ -90,11 +91,18 @@ export async function postSlackQuestion(args: {
const displayPrefix = prefix ?? "";
const questionText = `${displayPrefix}${question}`;

// Synthetic placeholder thread ids (`task:` / `cron-job:` / `cron:`) are
// not valid Slack `thread_ts` values; if the runtime hands us one during
// a task/cron run, fall back to posting at the top of the channel rather
// than letting Slack reject the call with `invalid_thread_ts`.
const threadIsSynthetic = isSyntheticOwner(threadId);
const threadField = threadIsSynthetic ? {} : { thread_ts: threadId };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve synthetic question routing

When a task/cron run emits an ask_user question, this drops the synthetic thread_ts and posts the Slack question as a new top-level message, but the pending question is still stored under the synthetic thread id in request-run.ts (setPendingQuestion(request.channelId, request.threadId, ...)). Slack button handling later derives the thread from the posted message (body.message?.thread_ts || body.message?.ts in commands.ts), so a click/reply on this top-level question is routed under the real Slack timestamp instead of the synthetic thread and getPendingQuestion will not find it. In that scenario the user's answer starts a fresh turn instead of resuming the blocked task/cron run; the fallback needs to seed/mirror the synthetic session or otherwise map the posted question's ts back before exposing interactive/plain questions top-level.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — flagging this as an explicit follow-up rather than fixing in this PR.

This PR is intentionally narrow: stop posting an invalid thread_ts so Slack stops rejecting cron/task questions with invalid_thread_ts (Sentry ODE-DEAMON-7). Before this change, the question was lost entirely; after it, the question is at least visible at the top of the channel.

You're right that interactive button clicks / threaded replies under the new top-level message route through body.message?.thread_ts || body.message?.ts and so won't match the synthetic-thread pendingQuestion written by request-run.ts:495. That means: with this PR, the user sees the question but pressing a button starts a fresh turn instead of resuming the blocked cron/task run — strictly better than the previous "silently swallowed" failure mode, but still incomplete.

The proper fix needs one of:

  • Mirror pendingQuestion under both the synthetic threadId and the posted message's ts once we know it
  • Or re-key the pending-question entry after chat.postMessage returns the real ts
  • Or seed the cron/task session early (right after the first sendChannelMessage) so the synthetic id is gone by the time postSlackQuestion runs

That's a session/state-routing change that belongs in its own PR. I'd rather land this fix now to stop the Sentry bleed and tackle the routing in a focused follow-up than block on it here. Will open a tracking issue.


const postPlainText = async (): Promise<string | undefined> => {
const optionText = options.length > 0 ? `\nOptions: ${options.join(" / ")}` : "";
const result = await client.chat.postMessage({
channel: channelId,
thread_ts: threadId,
...threadField,
text: `${questionText}${optionText}`,
token,
});
Expand All @@ -112,7 +120,7 @@ export async function postSlackQuestion(args: {
try {
const result = await client.chat.postMessage({
channel: channelId,
thread_ts: threadId,
...threadField,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve the channel token before posting synthetic questions

When this synthetic branch turns a task/cron ask_user question into a top-level Slack post, it still uses the token selected by createSlackAdapter.sendQuestion, which calls getSlackBotToken(channelId, threadId) with the fake task:/cron-job: id rather than doing the workspace-first channel lookup used for top-level sends. In a multi-workspace install where the scheduled channel has not yet been bound in the Slack registry, that can fall back to the first registered bot token, so these newly top-level Block Kit/plain question posts fail with channel_not_found/not_in_channel even though sendMessage was fixed; the fresh evidence is that the question path still passes the old token into this spread.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 1d4e434. The sendQuestion adapter now mirrors the token-resolution logic from sendMessage (client.ts:321): when threadId is a synthetic placeholder, resolve via getWorkspaceBotTokenForChannel(channelId) first, falling back to getSlackBotToken(channelId). The real-thread path is unchanged so existing registry bindings keep winning for in-flight replies.

const threadIsSynthetic = isSyntheticOwner(threadId);
const token = getSlackBotTokenForProcessor(processorId)
  ?? (threadIsSynthetic
    ? (getWorkspaceBotTokenForChannel(channelId) ?? getSlackBotToken(channelId))
    : getSlackBotToken(channelId, threadId));

Full suite still green (408 pass, 1 skip).

text: questionText,
blocks: [
{
Expand Down
75 changes: 70 additions & 5 deletions packages/ims/slack/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,29 @@ export async function sendMessage(
const formattedText = markdownToSlack(text);
const chunks = splitForSlack(formattedText);
const workspace = slackAuthRegistry.getChannelWorkspaceName(rawChannelId) || "unknown";
const botToken = getSlackBotTokenForProcessor(processorId) ?? getSlackBotToken(channelId, threadId);

// A "synthetic" thread id (`task:{id}` / `cron-job:{id}:{run}` / `cron:{id}`)
// is an internal placeholder used by Ode's task/cron schedulers before a
// real Slack `thread_ts` exists. Slack rejects these with `invalid_thread_ts`
// because they are not valid message timestamps. When the agent runtime
// emits intermediate output during a task/cron run, fall back to a
// top-level channel post (no `thread_ts`) so the message still lands in
// the channel instead of being lost + captured as a Sentry error.
const threadIsSynthetic = isSyntheticOwner(threadId);

// Token resolution. For real threads, prefer the registry-bound token for
// this (channel, thread) — that's the token the inbound router observed
// delivering the parent message and is the safest choice for replies. For
// synthetic placeholders the call has degenerated to a top-level channel
// post (see below); the registry has no entry for a fake `thread_ts`, so
// resolve via the channel's workspace first (mirrors `sendChannelMessage`)
// before falling back to `getSlackBotToken` to avoid the multi-workspace
// edge case where `getSlackBotToken` may return the first registered token
// instead of the one for `rawChannelId`.
const botToken = getSlackBotTokenForProcessor(processorId)
?? (threadIsSynthetic
? (getWorkspaceBotTokenForChannel(channelId) ?? getSlackBotToken(channelId))
: getSlackBotToken(channelId, threadId));

if (!botToken) {
log.warn("No Slack bot token available for channel", { channelId });
Expand All @@ -320,6 +342,7 @@ export async function sendMessage(
workspace,
channel: channelId,
thread: threadId,
threadIsSynthetic,
botTokenLast6: tokenLast6(botToken),
text,
chunks: chunks.length,
Expand All @@ -329,6 +352,7 @@ export async function sendMessage(
workspace,
channel: channelId,
thread: threadId,
threadIsSynthetic,
botTokenLast6: tokenLast6(botToken),
text,
chunks: chunks.length,
Expand All @@ -346,7 +370,7 @@ export async function sendMessage(
try {
const result = await slackApp.client.chat.postMessage({
channel: rawChannelId,
thread_ts: threadId,
...(threadIsSynthetic ? {} : { thread_ts: threadId }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the channel workspace token for synthetic top-level sends

When threadIsSynthetic is true this call is now a top-level channel post, but the token was still resolved with getSlackBotToken(channelId, threadId) before this branch. In multi-workspace installs where a scheduled task/cron fires before that channel has been seen by the message router, getSlackBotToken can fall back to the first registered token instead of the token for rawChannelId; the existing top-level helper avoids that by consulting getWorkspaceBotTokenForChannel(channelId) first. In that context the new fallback can still fail with channel_not_found/not_in_channel even though dropping thread_ts was supposed to make the message deliverable.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 6da6431. Token resolution now mirrors sendChannelMessage when threadIsSynthetic is true:

getSlackBotTokenForProcessor(processorId)
  ?? (threadIsSynthetic
    ? (getWorkspaceBotTokenForChannel(channelId) ?? getSlackBotToken(channelId))
    : getSlackBotToken(channelId, threadId));

The real-thread path is unchanged so existing registry bindings keep winning for in-flight replies. Suite still green (408 pass, 1 skip).

text: chunk,
token: botToken,
});
Expand All @@ -358,7 +382,15 @@ export async function sendMessage(
});
lastTs = result.ts;
if (botToken && result.ts) {
slackAuthRegistry.setThreadBotToken(rawChannelId, threadId, botToken);
// Don't bind a real bot token to a synthetic placeholder thread id —
// the real platform-assigned thread is whatever `result.ts` is for
// the first message of the run. `setMessageBotToken` covers the
// outgoing message itself; the cron/task scheduler seeds the
// session for the real thread via `seedCronChannelThreadSession` /
// `seedChannelThreadSession`.
if (!threadIsSynthetic) {
slackAuthRegistry.setThreadBotToken(rawChannelId, threadId, botToken);
}
slackAuthRegistry.setMessageBotToken(rawChannelId, result.ts, botToken);
}
} catch (err) {
Expand Down Expand Up @@ -627,22 +659,55 @@ function createSlackAdapter(processorId?: string): IMAdapter {
options: string[] | undefined,
prefix?: string
) => {
const token = getSlackBotTokenForProcessor(processorId) ?? getSlackBotToken(channelId, threadId);
// Mirror the token-resolution logic in `sendMessage`: when the thread
// id is a synthetic placeholder (`task:` / `cron-job:` / `cron:`), the
// posted question degenerates to a top-level channel post inside
// `postSlackQuestion`, so the registry has no entry for that fake
// `thread_ts`. Resolve via the channel's workspace first to avoid the
// multi-workspace edge case where `getSlackBotToken(channelId, fakeTs)`
// can fall back to the first registered token instead of the one bound
// to `channelId`.
const threadIsSynthetic = isSyntheticOwner(threadId);
const token = getSlackBotTokenForProcessor(processorId)
?? (threadIsSynthetic
? (getWorkspaceBotTokenForChannel(channelId) ?? getSlackBotToken(channelId))
: getSlackBotToken(channelId, threadId));
Comment on lines +672 to +674

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bind synthetic question threads to the resolved Slack token

When threadIsSynthetic is true, this correctly resolves the channel's workspace token for the top-level question, but the returned Slack ts is never registered as a message/thread token. The button path later calls handleButtonSelection, which looks up the token from the clicked selection message or the question thread ts; for a scheduled task/cron question in a multi-workspace install where the channel has not already been bound by the message router, that lookup falls back to an empty/default processor, so multi-question follow-ups or error replies after the first button click can be sent with the wrong workspace token. Please store the resolved token against the postSlackQuestion result when the synthetic post creates the real thread.

Useful? React with 👍 / 👎.

if (!token) {
// No token -> fall through to plain-text sendMessage so the question
// still gets delivered through whatever channel/path the caller has.
const optionText = options && options.length > 0 ? `\nOptions: ${options.join(" / ")}` : "";
return sendMessage(channelId, threadId, `${prefix ?? ""}${question}${optionText}`, processorId);
}
const { postSlackQuestion } = await import("./api");
return postSlackQuestion({
const questionMessageTs = await postSlackQuestion({
channelId,
threadId,
question,
options,
prefix,
token,
});
// The synthetic branch above turns the question into a top-level channel
// post. The handler that processes the user's button click / threaded
// reply later looks up the bot token via BOTH
// getMessageBotToken(channel, selectionMessageTs) → this is the ts
// of the user's selection reply (not the question), created inside
// `commands.ts` via `chat.postMessage`, so no registry entry exists.
// getThreadBotToken(channel, threadId) → for a top-level
// question, Slack derives `threadId` from
// `body.message?.thread_ts || body.message?.ts`, which resolves to
// the question message's own ts.
// Register the resolved `token` under both the message-token map (for
// parity with sendMessage's outgoing binding) AND the thread-token map
// keyed by the question ts, so the button-selection path can recover
// the correct workspace token in multi-workspace installs where the
// channel has not yet been bound by the message router (Codex P2 on
// PR #212).
if (questionMessageTs) {
slackAuthRegistry.setMessageBotToken(channelId, questionMessageTs, token);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bind synthetic question threads as thread tokens

In a multi-workspace install where a task/cron question is posted as a new top-level Slack message and the user clicks a button, this new binding still won't be found by the button path: commands.ts passes the selection reply's ts as messageTs and the question ts as threadId, while handleButtonSelection checks getMessageBotToken(channel, selectionTs) and then getThreadBotToken(channel, questionTs). Since this line stores the question ts only in the message-token map, the lookup can still fall back to the default/first workspace processor and mis-route follow-up questions or error replies; the fresh evidence is that the attempted fix writes the wrong registry map for the value later used as the thread id.

Useful? React with 👍 / 👎.

slackAuthRegistry.setThreadBotToken(channelId, questionMessageTs, token);
}
return questionMessageTs;
},
updateMessage: (channelId: string, messageTs: string, text: string) =>
updateMessage(channelId, messageTs, text, processorId),
Expand Down
Loading