Skip to content
Merged
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
118 changes: 75 additions & 43 deletions packages/junior/src/chat/slack/assistant-thread/title.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,17 @@ import type { ThreadArtifactsState } from "@/chat/state/artifacts";
import type { ThreadConversationState } from "@/chat/state/conversation";

/**
* Best-effort assistant-thread title update for DM assistant threads.
* Best-effort conversation title generation for all Slack conversations.
*
* Title generation is intentionally detached from reply generation and visible
* reply delivery. Stable Slack permission failures are treated as a terminal
* skip for the current source message so later turns do not keep paying for
* the same fast-model title generation call.
* reply delivery. For DM assistant threads the generated title is also pushed
* to Slack via `setAssistantTitle`. For channel conversations the title is
* generated and returned for dashboard reporting only — the Slack API for
* setting thread titles is DM-only and is not called.
*
* Stable Slack permission failures on DM title updates are treated as a
* terminal skip for the current source message so later turns do not keep
* paying for the same fast-model call that Slack will reject.
*/
export function maybeUpdateAssistantTitle(args: {
assistantThreadContext?: {
Expand All @@ -39,8 +44,7 @@ export function maybeUpdateAssistantTitle(args: {
const assistantThreadContext = args.assistantThreadContext;
if (
!assistantThreadContext?.channelId ||
!assistantThreadContext.threadTs ||
!isDmChannel(assistantThreadContext.channelId)
!assistantThreadContext.threadTs
) {
return Promise.resolve(undefined);
}
Expand All @@ -53,46 +57,13 @@ export function maybeUpdateAssistantTitle(args: {
return Promise.resolve(undefined);
}

const isDm = isDmChannel(assistantThreadContext.channelId);

return (async () => {
let title: string | undefined;
try {
const title = await args.generateThreadTitle(titleSourceMessage.text);
await args
.getSlackAdapter()
.setAssistantTitle(
assistantThreadContext.channelId,
assistantThreadContext.threadTs,
title,
);
return { sourceMessageId: titleSourceMessage.id, title };
title = await args.generateThreadTitle(titleSourceMessage.text);
} catch (error) {
const slackErrorCode = getSlackApiErrorCode(error);
const assistantTitleErrorAttributes = {
"app.slack.assistant_title.outcome": "permission_denied",
...(slackErrorCode
? {
"app.slack.assistant_title.error_code": slackErrorCode,
}
: {}),
};
if (isSlackTitlePermissionError(error)) {
// Persist the source message anyway so later turns do not keep paying
// for another fast-model title generation call Slack will reject.
setSpanAttributes(assistantTitleErrorAttributes);
logError(
"thread_title_generation_permission_denied",
{
slackThreadId: args.threadId,
slackUserId: args.requesterId,
slackChannelId: args.channelId,
runId: args.runId,
assistantUserName: args.assistantUserName,
modelId: args.modelId,
},
assistantTitleErrorAttributes,
"Skipping thread title update due to Slack permission error",
);
return { sourceMessageId: titleSourceMessage.id };
}
logWarn(
"thread_title_generation_failed",
{
Expand All @@ -111,5 +82,66 @@ export function maybeUpdateAssistantTitle(args: {
);
return undefined;
}

// Only DM assistant threads support the Slack setAssistantTitle API.
if (isDm) {
try {
await args
.getSlackAdapter()
.setAssistantTitle(
assistantThreadContext.channelId,
assistantThreadContext.threadTs,
title,
);
} catch (error) {
const slackErrorCode = getSlackApiErrorCode(error);
const assistantTitleErrorAttributes = {
"app.slack.assistant_title.outcome": "permission_denied",
...(slackErrorCode
? {
"app.slack.assistant_title.error_code": slackErrorCode,
}
: {}),
};
if (isSlackTitlePermissionError(error)) {
// Persist the source message id so later turns do not keep paying
// for another fast-model call that Slack will reject. The generated
// title is still returned for dashboard reporting.
setSpanAttributes(assistantTitleErrorAttributes);
logError(
"thread_title_generation_permission_denied",
{
slackThreadId: args.threadId,
slackUserId: args.requesterId,
slackChannelId: args.channelId,
runId: args.runId,
assistantUserName: args.assistantUserName,
modelId: args.modelId,
},
assistantTitleErrorAttributes,
"Skipping Slack thread title update due to permission error",
);
} else {
logWarn(
"thread_title_slack_update_failed",
{
slackThreadId: args.threadId,
slackUserId: args.requesterId,
slackChannelId: args.channelId,
runId: args.runId,
assistantUserName: args.assistantUserName,
modelId: args.modelId,
},
{
"exception.message":
error instanceof Error ? error.message : String(error),
},
"Slack thread title update failed",
);
}
}
}

return { sourceMessageId: titleSourceMessage.id, title };
})();
}
191 changes: 191 additions & 0 deletions packages/junior/tests/unit/slack/assistant-thread-title.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { describe, expect, it, vi } from "vitest";
import type { ThreadArtifactsState } from "@/chat/state/artifacts";
import type { ThreadConversationState } from "@/chat/state/conversation";
import { maybeUpdateAssistantTitle } from "@/chat/slack/assistant-thread/title";

const DM_CHANNEL_ID = "D12345";
const CHANNEL_ID = "C12345";
const PRIVATE_CHANNEL_ID = "G12345";
const THREAD_TS = "1700000000.000001";
const USER_MESSAGE_ID = "msg_001";
const USER_MESSAGE_TEXT = "How do I debug memory leaks in Node?";
const GENERATED_TITLE = "Debugging Node.js Memory Leaks";

function makeConversation(
override?: Partial<ThreadConversationState>,
): ThreadConversationState {
return {
backfill: {},
compactions: [],
messages: [
{
id: USER_MESSAGE_ID,
role: "user",
text: USER_MESSAGE_TEXT,
createdAtMs: 1700000000000,
},
],
piMessages: [],
processing: {},
schemaVersion: 1,
stats: {
compactedMessageCount: 0,
estimatedContextTokens: 0,
totalMessageCount: 1,
updatedAtMs: 1700000000000,
},
vision: { byFileId: {} },
...override,
};
}

function makeArtifacts(
override?: Partial<ThreadArtifactsState>,
): ThreadArtifactsState {
return { ...override };
}

function makeArgs(
channelId: string,
overrides?: {
artifacts?: Partial<ThreadArtifactsState>;
generateThreadTitle?: () => Promise<string>;
setAssistantTitle?: (...args: unknown[]) => Promise<void>;
},
) {
const setAssistantTitle =
overrides?.setAssistantTitle ?? vi.fn().mockResolvedValue(undefined);
const generateThreadTitle =
overrides?.generateThreadTitle ??
vi.fn().mockResolvedValue(GENERATED_TITLE);

return {
assistantThreadContext: { channelId, threadTs: THREAD_TS },
assistantUserName: "junior",
artifacts: makeArtifacts(overrides?.artifacts),
channelId,
conversation: makeConversation(),
generateThreadTitle,
getSlackAdapter: () => ({ setAssistantTitle }),
modelId: "fast-model",
requesterId: "U_USER",
runId: "run_001",
threadId: `slack:${channelId}:${THREAD_TS}`,
_setAssistantTitle: setAssistantTitle,
};
}

describe("maybeUpdateAssistantTitle", () => {
describe("channel thread (non-DM)", () => {
it("generates and returns a title for a public channel", async () => {
const args = makeArgs(CHANNEL_ID);
const result = await maybeUpdateAssistantTitle(args);

expect(result).toEqual({
sourceMessageId: USER_MESSAGE_ID,
title: GENERATED_TITLE,
});
expect(args.generateThreadTitle).toHaveBeenCalledWith(USER_MESSAGE_TEXT);
});

it("does NOT call setAssistantTitle for a public channel", async () => {
const args = makeArgs(CHANNEL_ID);
await maybeUpdateAssistantTitle(args);

expect(args._setAssistantTitle).not.toHaveBeenCalled();
});

it("generates and returns a title for a private channel", async () => {
const args = makeArgs(PRIVATE_CHANNEL_ID);
const result = await maybeUpdateAssistantTitle(args);

expect(result).toEqual({
sourceMessageId: USER_MESSAGE_ID,
title: GENERATED_TITLE,
});
expect(args._setAssistantTitle).not.toHaveBeenCalled();
});
});

describe("DM thread", () => {
it("generates a title and calls setAssistantTitle", async () => {
const args = makeArgs(DM_CHANNEL_ID);
const result = await maybeUpdateAssistantTitle(args);

expect(result).toEqual({
sourceMessageId: USER_MESSAGE_ID,
title: GENERATED_TITLE,
});
expect(args._setAssistantTitle).toHaveBeenCalledWith(
DM_CHANNEL_ID,
THREAD_TS,
GENERATED_TITLE,
);
});

it("returns the generated title even when setAssistantTitle throws a permission error", async () => {
const permissionError = { data: { error: "no_permission" } };
const args = makeArgs(DM_CHANNEL_ID, {
setAssistantTitle: vi.fn().mockRejectedValue(permissionError),
});

const result = await maybeUpdateAssistantTitle(args);

expect(result).toEqual({
sourceMessageId: USER_MESSAGE_ID,
title: GENERATED_TITLE,
});
});

it("returns the generated title even when setAssistantTitle throws a non-permission error", async () => {
const args = makeArgs(DM_CHANNEL_ID, {
setAssistantTitle: vi.fn().mockRejectedValue(new Error("network fail")),
});

const result = await maybeUpdateAssistantTitle(args);

expect(result).toEqual({
sourceMessageId: USER_MESSAGE_ID,
title: GENERATED_TITLE,
});
});
});

describe("early returns", () => {
it("returns undefined when assistantThreadContext is missing", async () => {
const args = {
...makeArgs(CHANNEL_ID),
assistantThreadContext: undefined,
};
const result = await maybeUpdateAssistantTitle(args);
expect(result).toBeUndefined();
});

it("returns undefined when there is no human message in the conversation", async () => {
const args = makeArgs(CHANNEL_ID);
args.conversation = makeConversation({ messages: [] });
const result = await maybeUpdateAssistantTitle(args);
expect(result).toBeUndefined();
expect(args.generateThreadTitle).not.toHaveBeenCalled();
});

it("skips generation when source message id matches existing artifact (dedup)", async () => {
const args = makeArgs(CHANNEL_ID, {
artifacts: { assistantTitleSourceMessageId: USER_MESSAGE_ID },
});
const result = await maybeUpdateAssistantTitle(args);
expect(result).toBeUndefined();
expect(args.generateThreadTitle).not.toHaveBeenCalled();
});

it("returns undefined when title generation throws", async () => {
const args = makeArgs(CHANNEL_ID, {
generateThreadTitle: vi
.fn()
.mockRejectedValue(new Error("model error")),
});
const result = await maybeUpdateAssistantTitle(args);
expect(result).toBeUndefined();
});
});
});
Loading