diff --git a/packages/junior/src/chat/runtime/processing-reaction.ts b/packages/junior/src/chat/runtime/processing-reaction.ts index 643b356e..f10f7a5d 100644 --- a/packages/junior/src/chat/runtime/processing-reaction.ts +++ b/packages/junior/src/chat/runtime/processing-reaction.ts @@ -61,10 +61,31 @@ export async function startSlackProcessingReaction(args: { return noProcessingReaction; } + return startSlackProcessingReactionForMessage({ + channelId, + timestamp: messageTs, + logException: args.logException, + logContext: args.logContext, + }); +} + +/** Start Junior's automatic Slack processing reaction for a known Slack message. */ +export async function startSlackProcessingReactionForMessage(args: { + channelId: string; + timestamp: string; + logException: ( + error: unknown, + eventName: string, + context?: Record, + attributes?: Record, + body?: string, + ) => string | undefined; + logContext: Record; +}): Promise { try { await addReactionToMessage({ - channelId, - timestamp: messageTs, + channelId: args.channelId, + timestamp: args.timestamp, emoji: PROCESSING_REACTION_EMOJI, }); } catch (error) { @@ -74,7 +95,7 @@ export async function startSlackProcessingReaction(args: { args.logContext, { "app.slack.action": "reactions.add", - "messaging.message.id": messageTs, + "messaging.message.id": args.timestamp, ...getSlackErrorObservabilityAttributes(error), }, "Failed to add Slack processing reaction", @@ -94,8 +115,8 @@ export async function startSlackProcessingReaction(args: { try { await removeReactionFromMessage({ - channelId, - timestamp: messageTs, + channelId: args.channelId, + timestamp: args.timestamp, emoji: PROCESSING_REACTION_EMOJI, }); } catch (error) { @@ -105,7 +126,7 @@ export async function startSlackProcessingReaction(args: { args.logContext, { "app.slack.action": "reactions.remove", - "messaging.message.id": messageTs, + "messaging.message.id": args.timestamp, ...getSlackErrorObservabilityAttributes(error), }, "Failed to remove Slack processing reaction", diff --git a/packages/junior/src/chat/runtime/reply-executor.ts b/packages/junior/src/chat/runtime/reply-executor.ts index deb1458d..d60275d7 100644 --- a/packages/junior/src/chat/runtime/reply-executor.ts +++ b/packages/junior/src/chat/runtime/reply-executor.ts @@ -67,6 +67,7 @@ import { getAgentTurnDiagnosticsAttributes, } from "@/chat/services/turn-failure-response"; import { buildTurnContinuationResponse } from "@/chat/services/turn-continuation-response"; +import { buildAuthPauseResponse } from "@/chat/services/auth-pause-response"; export interface ReplyExecutorServices { generateAssistantReply: typeof generateAssistantReplyImpl; @@ -222,6 +223,26 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { throw error; } }; + const postAuthPauseNotice = async (): Promise => { + try { + await beforeFirstResponsePost(); + await thread.post( + buildSlackOutputMessage(buildAuthPauseResponse()), + ); + } catch (error) { + logException( + error, + "slack_auth_pause_notice_post_failed", + turnTraceContext, + { + "app.slack.reply_stage": "thread_reply_auth_pause_notice", + ...(messageTs ? { "messaging.message.id": messageTs } : {}), + ...getSlackErrorObservabilityAttributes(error), + }, + "Failed to post auth pause notice", + ); + } + }; const activeTurnId = preparedState.conversation.processing.activeTurnId; if (conversationId && activeTurnId) { const resumeRequest = @@ -592,6 +613,7 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { isRetryableTurnError(error, "mcp_auth_resume") || isRetryableTurnError(error, "plugin_auth_resume") ) { + await postAuthPauseNotice(); completeAuthPauseTurn({ conversation: preparedState.conversation, sessionId: error.metadata?.sessionId ?? turnId, diff --git a/packages/junior/src/chat/runtime/slack-resume.ts b/packages/junior/src/chat/runtime/slack-resume.ts index cf1fd6f1..27c29731 100644 --- a/packages/junior/src/chat/runtime/slack-resume.ts +++ b/packages/junior/src/chat/runtime/slack-resume.ts @@ -28,6 +28,11 @@ import { } from "@/chat/slack/reply"; import { postSlackMessage as postSlackApiMessage } from "@/chat/slack/outbound"; import { getStateAdapter } from "@/chat/state/adapter"; +import { + startSlackProcessingReactionForMessage, + type ProcessingReactionSession, +} from "@/chat/runtime/processing-reaction"; +import { buildAuthPauseResponse } from "@/chat/services/auth-pause-response"; function resolveReplyTimeoutMs(explicitTimeoutMs?: number): number | undefined { if (typeof explicitTimeoutMs === "number" && explicitTimeoutMs > 0) { @@ -55,7 +60,7 @@ async function postSlackMessageBestEffort( text, }); } catch { - // The connected notice should not decide whether the resumed turn succeeds. + // Resume-side status notices should not decide whether the turn succeeds. } } @@ -103,6 +108,7 @@ interface ResumeSlackTurnArgs { messageText: string; channelId: string; threadTs: string; + messageTs?: string; replyContext?: ReplyRequestContext; lockKey?: string; initialText?: string; @@ -281,10 +287,19 @@ export async function resumeSlackTurn(args: ResumeSlackTurnArgs) { channelId: args.channelId, threadTs: args.threadTs, }); + let processingReaction: ProcessingReactionSession | undefined; let deferredPauseKind: "auth" | "timeout" | undefined; let deferredPauseHandler: (() => Promise) | undefined; let deferredFailureHandler: (() => Promise) | undefined; try { + if (args.messageTs) { + processingReaction = await startSlackProcessingReactionForMessage({ + channelId: args.channelId, + timestamp: args.messageTs, + logException, + logContext: { ...getResumeLogContext(args, lockKey) }, + }); + } if (args.initialText) { await postSlackMessageBestEffort( args.channelId, @@ -370,12 +385,20 @@ export async function resumeSlackTurn(args: ResumeSlackTurnArgs) { }; } } finally { + await processingReaction?.stop(); await stateAdapter.releaseLock(lock); } if (deferredPauseHandler) { try { await deferredPauseHandler(); + if (deferredPauseKind === "auth") { + await postSlackMessageBestEffort( + args.channelId, + args.threadTs, + buildAuthPauseResponse(), + ); + } if (deferredPauseKind === "timeout") { await postTurnContinuationNoticeBestEffort({ lockKey, @@ -405,6 +428,7 @@ export async function resumeAuthorizedRequest(args: { messageText: string; channelId: string; threadTs: string; + messageTs?: string; connectedText: string; replyContext?: ReplyRequestContext; lockKey?: string; @@ -419,6 +443,7 @@ export async function resumeAuthorizedRequest(args: { messageText: args.messageText, channelId: args.channelId, threadTs: args.threadTs, + messageTs: args.messageTs, replyContext: args.replyContext, lockKey: args.lockKey, initialText: args.connectedText, diff --git a/packages/junior/src/chat/runtime/turn-user-message.ts b/packages/junior/src/chat/runtime/turn-user-message.ts index 7990181d..2deb5097 100644 --- a/packages/junior/src/chat/runtime/turn-user-message.ts +++ b/packages/junior/src/chat/runtime/turn-user-message.ts @@ -4,6 +4,13 @@ import type { ThreadConversationState, } from "@/chat/state/conversation"; +function normalizeSlackMessageTs( + value: string | undefined, +): string | undefined { + const trimmed = value?.trim(); + return trimmed && /^\d+(?:\.\d+)?$/.test(trimmed) ? trimmed : undefined; +} + /** Return the user message for a persisted turn/session, if one exists. */ export function getTurnUserMessage( conversation: ThreadConversationState, @@ -30,6 +37,16 @@ export function getTurnUserMessageId( return getTurnUserMessage(conversation, sessionId)?.id; } +/** Return the Slack timestamp for the user message that a resumed turn acts on. */ +export function getTurnUserSlackMessageTs( + message: ConversationMessage | undefined, +): string | undefined { + return ( + normalizeSlackMessageTs(message?.meta?.slackTs) ?? + normalizeSlackMessageTs(message?.id) + ); +} + /** Rebuild attachment context for a resumed turn from the persisted user message. */ export function getTurnUserReplyAttachmentContext( message: ConversationMessage | undefined, diff --git a/packages/junior/src/chat/services/auth-pause-response.ts b/packages/junior/src/chat/services/auth-pause-response.ts new file mode 100644 index 00000000..00a22137 --- /dev/null +++ b/packages/junior/src/chat/services/auth-pause-response.ts @@ -0,0 +1,7 @@ +const AUTH_PAUSE_RESPONSE = + "I need authorization to continue. Check your private link to connect."; + +/** Build the visible Slack thread note for an auth-paused turn. */ +export function buildAuthPauseResponse(): string { + return AUTH_PAUSE_RESPONSE; +} diff --git a/packages/junior/src/chat/slack/message.ts b/packages/junior/src/chat/slack/message.ts index 68cba2b3..666f3b03 100644 --- a/packages/junior/src/chat/slack/message.ts +++ b/packages/junior/src/chat/slack/message.ts @@ -1,5 +1,9 @@ import type { Message } from "chat"; +function isSlackMessageTs(value: string): boolean { + return /^\d+(?:\.\d+)?$/.test(value.trim()); +} + /** * Preserve the native Slack message timestamp when a synthetic message ID is * used for routing or deduplication. @@ -7,11 +11,11 @@ import type { Message } from "chat"; export function getSlackMessageTs( message: Pick, ): string { - if ( - message.id.endsWith(":message_changed_mention") && - message.raw && - typeof message.raw === "object" - ) { + if (isSlackMessageTs(message.id)) { + return message.id; + } + + if (message.raw && typeof message.raw === "object") { const ts = (message.raw as Record).ts; if (typeof ts === "string" && ts.length > 0) { return ts; diff --git a/packages/junior/src/handlers/mcp-oauth-callback.ts b/packages/junior/src/handlers/mcp-oauth-callback.ts index d2204811..0e26c825 100644 --- a/packages/junior/src/handlers/mcp-oauth-callback.ts +++ b/packages/junior/src/handlers/mcp-oauth-callback.ts @@ -18,6 +18,7 @@ import { getTurnUserMessage, getTurnUserReplyAttachmentContext, getTurnUserMessageId, + getTurnUserSlackMessageTs, } from "@/chat/runtime/turn-user-message"; import { buildConversationContext, @@ -229,6 +230,7 @@ async function resumeAuthorizedMcpTurn(args: { messageText: userMessage.text, channelId: authSession.channelId, threadTs: authSession.threadTs, + messageTs: getTurnUserSlackMessageTs(userMessage), lockKey: authSession.conversationId, connectedText: "", replyContext: { diff --git a/packages/junior/src/handlers/oauth-callback.ts b/packages/junior/src/handlers/oauth-callback.ts index 2ca7cfbc..77da8a88 100644 --- a/packages/junior/src/handlers/oauth-callback.ts +++ b/packages/junior/src/handlers/oauth-callback.ts @@ -38,6 +38,7 @@ import { } from "@/chat/plugins/auth/oauth-request"; import { getTurnUserMessage, + getTurnUserSlackMessageTs, getTurnUserReplyAttachmentContext, } from "@/chat/runtime/turn-user-message"; import { @@ -253,6 +254,7 @@ async function resumeCheckpointedOAuthTurn( messageText: stored.pendingMessage ?? userMessage.text, channelId: stored.channelId, threadTs: stored.threadTs, + messageTs: getTurnUserSlackMessageTs(userMessage), lockKey: stored.resumeConversationId, initialText: "", replyContext: { @@ -350,16 +352,17 @@ async function resumePendingOAuthMessage( const conversation = coerceThreadConversationState( await getPersistedThreadState(threadId), ); - const latestUserMessageId = [...conversation.messages] + const latestUserMessage = [...conversation.messages] .reverse() - .find((message) => message.role === "user")?.id; + .find((message) => message.role === "user"); const conversationContext = buildConversationContext(conversation, { - excludeMessageId: latestUserMessageId, + excludeMessageId: latestUserMessage?.id, }); await resumeAuthorizedRequest({ messageText: stored.pendingMessage, channelId: stored.channelId, threadTs: stored.threadTs, + messageTs: getTurnUserSlackMessageTs(latestUserMessage), connectedText: "", replyContext: { requester: { userId: stored.userId }, diff --git a/packages/junior/tests/integration/mcp-auth-runtime-slack.test.ts b/packages/junior/tests/integration/mcp-auth-runtime-slack.test.ts index bf2075f0..2e431a27 100644 --- a/packages/junior/tests/integration/mcp-auth-runtime-slack.test.ts +++ b/packages/junior/tests/integration/mcp-auth-runtime-slack.test.ts @@ -248,6 +248,25 @@ async function mirrorThreadStateToAdapter(thread: TestThread): Promise { .set(`thread-state:${thread.id}`, thread.getState()); } +function expectProcessingReactionLifecycles(args: { + channel: string; + count: number; + timestamp: string; +}): void { + const call = () => + expect.objectContaining({ + params: expect.objectContaining({ + channel: args.channel, + timestamp: args.timestamp, + name: "eyes", + }), + }); + const expected = Array.from({ length: args.count }, call); + + expect(getCapturedSlackApiCalls("reactions.add")).toEqual(expected); + expect(getCapturedSlackApiCalls("reactions.remove")).toEqual(expected); +} + describe("mcp auth runtime slack integration", () => { beforeEach(async () => { resetAgentProbe(); @@ -322,6 +341,11 @@ describe("mcp auth runtime slack integration", () => { userId: "U123", userName: "dcramer", }, + raw: { + channel: "C123", + ts: "1700000000.002", + thread_ts: "1700000000.001", + }, }), ); @@ -340,8 +364,17 @@ describe("mcp auth runtime slack integration", () => { }), }), ]); - expect(thread.posts).toHaveLength(0); + expect(thread.posts).toEqual([ + expect.objectContaining({ + markdown: expect.stringContaining("private link"), + }), + ]); expect(getCapturedSlackApiCalls("chat.postMessage")).toHaveLength(0); + expectProcessingReactionLifecycles({ + channel: "C123", + timestamp: "1700000000.002", + count: 1, + }); const pendingAuthSession = await mcpAuthStoreModule.getLatestMcpAuthSessionForUserProvider( @@ -479,6 +512,11 @@ describe("mcp auth runtime slack integration", () => { }), }), ]); + expectProcessingReactionLifecycles({ + channel: "C123", + timestamp: "1700000000.002", + count: 2, + }); }); it("parks a subscribed-thread MCP auth challenge with the same pending-auth state", async () => { @@ -541,7 +579,11 @@ describe("mcp auth runtime slack integration", () => { expect(agentProbe.promptCallCount).toBe(1); expect(agentProbe.continueCallCount).toBe(0); - expect(thread.posts).toHaveLength(0); + expect(thread.posts).toEqual([ + expect.objectContaining({ + markdown: expect.stringContaining("private link"), + }), + ]); const pendingCheckpoint = await turnSessionStoreModule.getAgentTurnSessionCheckpoint( diff --git a/packages/junior/tests/integration/oauth-callback-slack.test.ts b/packages/junior/tests/integration/oauth-callback-slack.test.ts index b01a8b4e..80568f65 100644 --- a/packages/junior/tests/integration/oauth-callback-slack.test.ts +++ b/packages/junior/tests/integration/oauth-callback-slack.test.ts @@ -216,6 +216,9 @@ describe("oauth callback slack integration", () => { userId: "U123", userName: "dcramer", }, + meta: { + slackTs: "1700000000.010", + }, }, ], processing: { @@ -289,6 +292,24 @@ describe("oauth callback slack integration", () => { }), ]), ); + expect(getCapturedSlackApiCalls("reactions.add")).toEqual([ + expect.objectContaining({ + params: expect.objectContaining({ + channel: "C123", + timestamp: "1700000000.010", + name: "eyes", + }), + }), + ]); + expect(getCapturedSlackApiCalls("reactions.remove")).toEqual([ + expect.objectContaining({ + params: expect.objectContaining({ + channel: "C123", + timestamp: "1700000000.010", + name: "eyes", + }), + }), + ]); }); it("does not re-post the pending message when the checkpoint is already superseded", async () => { diff --git a/packages/junior/tests/integration/slack/bot-handlers.test.ts b/packages/junior/tests/integration/slack/bot-handlers.test.ts index ef5474e4..e9dbd17c 100644 --- a/packages/junior/tests/integration/slack/bot-handlers.test.ts +++ b/packages/junior/tests/integration/slack/bot-handlers.test.ts @@ -383,7 +383,11 @@ describe("bot handlers (integration)", () => { ), ).resolves.toBeUndefined(); - expect(thread.posts).toHaveLength(0); + expect(thread.posts).toEqual([ + expect.objectContaining({ + markdown: expect.stringContaining("private link"), + }), + ]); const state = thread.getState(); const conversation = ( state as { @@ -453,7 +457,11 @@ describe("bot handlers (integration)", () => { ), ).resolves.toBeUndefined(); - expect(thread.posts).toHaveLength(0); + expect(thread.posts).toEqual([ + expect.objectContaining({ + markdown: expect.stringContaining("private link"), + }), + ]); const state = thread.getState(); const conversation = ( state as { diff --git a/specs/agent-session-resumability-spec.md b/specs/agent-session-resumability-spec.md index 5457b7ac..909b36c4 100644 --- a/specs/agent-session-resumability-spec.md +++ b/specs/agent-session-resumability-spec.md @@ -3,7 +3,7 @@ ## Metadata - Created: 2026-03-05 -- Last Edited: 2026-05-13 +- Last Edited: 2026-05-19 ## Changelog @@ -15,6 +15,7 @@ - 2026-04-22: Added `superseded` checkpoint state and clarified that auth checkpoints do not keep `activeTurnId` alive; thread-local pending-auth state decides whether an auth-blocked request is still resumable. - 2026-05-06: Removed the public Slack auth-pause note; auth pauses complete the live turn after private auth-link delivery. - 2026-05-13: Clarified turn continuation as an idempotent checkpoint retry path, including user follow-up rescheduling and bounded lock-busy callback retries. +- 2026-05-19: Clarified that Slack auth pauses also post a visible URL-free acknowledgement owned by the Slack delivery contract. ## Status diff --git a/specs/chat-architecture-spec.md b/specs/chat-architecture-spec.md index 21cd6886..aa1e9f22 100644 --- a/specs/chat-architecture-spec.md +++ b/specs/chat-architecture-spec.md @@ -78,7 +78,7 @@ flowchart TD O -->|terminal failure| S[Capture failure and build fallback reply] R --> T[Schedule signed internal continuation callback] T --> U[Post durable continuation acknowledgement] - Q --> V[Private auth link; live turn ends] + Q --> V[Private auth link plus visible URL-free auth acknowledgement; live turn ends] V --> AB[OAuth/MCP callback resumes session] P --> W[Deliver finalized Slack reply/files] S --> W diff --git a/specs/oauth-flows-spec.md b/specs/oauth-flows-spec.md index d7840dca..db65f522 100644 --- a/specs/oauth-flows-spec.md +++ b/specs/oauth-flows-spec.md @@ -3,7 +3,7 @@ ## Metadata - Created: 2026-03-03 -- Last Edited: 2026-05-06 +- Last Edited: 2026-05-19 ## Changelog @@ -13,6 +13,7 @@ - 2026-04-17: Removed explicit model-facing auth commands and documented implicit runtime-owned OAuth initiation for plugin-backed commands. - 2026-04-22: Reframed auth-blocked work as completed Slack turns plus persisted thread-local `pendingAuth` state, documented deduped re-prompts, and limited auto-resume to the latest still-relevant blocked request. - 2026-05-06: Removed the public thread-visible auth-pause note; the private auth-link delivery is the only immediate auth handoff. +- 2026-05-19: Restored a visible URL-free Slack thread acknowledgement for auth pauses while keeping authorization links private. ## Status @@ -67,6 +68,7 @@ Agent: loads the matching plugin skill and runs the real provider command ├─ If auth is missing or stale: │ • runtime creates OAuth state │ • runtime privately delivers the authorization link + │ • runtime posts a brief visible thread note that authorization is needed │ • runtime checkpoints the turn as awaiting auth resume │ • runtime records thread-local `pendingAuth` └─ Current turn ends cleanly; it is not kept as the active in-flight turn @@ -98,6 +100,7 @@ Agent: calls an MCP tool from the same plugin ├─ MCP server responds with 401 / auth challenge ├─ MCP OAuth provider persists auth session state ├─ Runtime privately delivers the authorization link to the requesting user + ├─ Runtime posts a brief visible thread note that authorization is needed ├─ Turn checkpoint is written as awaiting auth resume ├─ Runtime records thread-local `pendingAuth` └─ Current turn ends cleanly; it is not kept as the active in-flight turn @@ -196,7 +199,7 @@ Providers define OAuth through plugin manifests: ## Security invariants - Authorization links are delivered privately to the requesting user only. -- The runtime must not post the authorization URL into the public thread or add a second public thread note just to announce that a private link was sent. +- The runtime must not post the authorization URL into the public thread. Slack-thread acknowledgements for auth pauses must stay URL-free and only say that authorization is needed and the private link was sent. - Authorization URLs are never returned to the model. - Tokens are stored server-side and never appear in sandbox files or model-visible tool arguments. - Leases are requester-bound; sandbox egress leases are scoped to one command activation. diff --git a/specs/security-policy.md b/specs/security-policy.md index f7bb8df2..e9489093 100644 --- a/specs/security-policy.md +++ b/specs/security-policy.md @@ -88,6 +88,7 @@ This policy applies to: - Authorization URLs contain user-specific CSRF state tokens and must **only** be visible to the requesting user. - Deliver authorization links via Slack `chat.postEphemeral` (channels) or `chat.postMessage` in 1:1 DMs (where the conversation is already private). - If private delivery fails, fall back to a DM to the user — **never** post an authorization URL as a visible message in a channel or group conversation. +- Visible Slack thread acknowledgements may say that authorization is needed and that a private link was sent, but must not include the authorization URL. - The agent must **never** receive or relay raw authorization URLs. If private delivery fails entirely, return an error instructing the user to DM the bot. ### Sentry baseline diff --git a/specs/slack-agent-delivery-spec.md b/specs/slack-agent-delivery-spec.md index db9579cb..57a69422 100644 --- a/specs/slack-agent-delivery-spec.md +++ b/specs/slack-agent-delivery-spec.md @@ -3,7 +3,7 @@ ## Metadata - Created: 2026-04-15 -- Last Edited: 2026-05-16 +- Last Edited: 2026-05-19 ## Changelog @@ -23,6 +23,7 @@ - 2026-05-06: Removed the public thread-visible auth-pause note; private auth-link delivery is the only immediate user-facing auth handoff before callback resume. - 2026-05-13: Added the turn-continuation acknowledgement and follow-up retry contract for awaiting continuation checkpoints. - 2026-05-16: Added automatic processing reactions for Slack messages Junior is handling or evaluating for handling. +- 2026-05-19: Restored the visible URL-free auth-pause thread acknowledgement and required processing reaction restoration during auth resumes. ## Status @@ -152,9 +153,10 @@ Current rules: 1. DM, explicit-mention, and subscribed-thread message handlers add `:eyes:` before turn preparation, passive reply classification, or assistant execution. 2. Junior removes that automatic `:eyes:` reaction when the handler completes, including reply, skip, opt-out, auth-pause, timeout-continuation, and fallback-error paths. -3. Processing-reaction add and remove calls are best effort. Failures are observable but must not fail the turn or change reply routing. -4. The automatic processing reaction is runtime-owned. It must not be exposed as model progress, and it must not count as a successful user-requested reaction tool call. -5. If the assistant explicitly uses the Slack reaction tool to add `:eyes:` to the same inbound message, Junior leaves the reaction in place instead of removing the automatic acknowledgement. +3. When an OAuth/MCP callback resumes an auth-paused request, Junior re-adds `:eyes:` to the original triggering Slack message while resumed processing runs, then removes it when the resumed handler completes. +4. Processing-reaction add and remove calls are best effort. Failures are observable but must not fail the turn or change reply routing. +5. The automatic processing reaction is runtime-owned. It must not be exposed as model progress, and it must not count as a successful user-requested reaction tool call. +6. If the assistant explicitly uses the Slack reaction tool to add `:eyes:` to the same inbound message, Junior leaves the reaction in place instead of removing the automatic acknowledgement. ### 6. Primary Reply Contract @@ -233,7 +235,7 @@ Current rules: 3. Resume success is defined by final visible Slack delivery, not only by successful assistant generation. 4. Persisted thread state is updated only after the final reply has been delivered to Slack. 5. Because live turns do not publish provisional assistant text, timeout continuation remains eligible until final reply delivery starts. -6. When a turn blocks on OAuth/MCP auth, Junior must end that live turn after privately delivering the auth link, clear `activeTurnId`, and persist thread-local pending-auth state. Do not post a second public thread reply just to say a private link was sent. +6. When a turn blocks on OAuth/MCP auth, Junior must privately deliver the auth link, post a brief visible thread acknowledgement that authorization is needed, clear `activeTurnId`, and persist thread-local pending-auth state. The visible acknowledgement must not include the auth URL or other secret-bearing state. 7. Automatic auth resumes must not post a separate public "account connected, continuing..." banner before the real resumed answer. The resumed answer itself is the visible continuation. 8. If auth completes after a newer thread message already superseded the blocked request, Junior stores the credentials but does not post a stale resumed answer. 9. When a turn checkpoint is scheduled for automatic continuation, Junior must post a durable thread acknowledgement that the turn is continuing in the background. Assistant status alone is not sufficient because it is best effort and expires independently of thread history.