Skip to content

Commit e1fd8f4

Browse files
dcramercodex
andcommitted
fix(slack): Preserve progress across agent resumes
Restore the active turn's latest reportProgress status when a queued agent run resumes instead of resetting Slack to generic loading. Use the same status update path for deterministic compaction and model handoff progress while keeping observer failures best effort. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
1 parent c94f409 commit e1fd8f4

13 files changed

Lines changed: 329 additions & 53 deletions

packages/junior/src/chat/agent/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,19 @@ async function executeAgentRunInPrivacyContext(
501501
const handoffThinkingLevel = toAgentThinkingLevel(
502502
thinkingSelection!.thinkingLevel,
503503
);
504+
void (async () => {
505+
await observers.onStatus?.({ text: "Switching models" });
506+
})().catch((error) => {
507+
logWarn(
508+
"assistant_status_observer_failed",
509+
{},
510+
{
511+
"exception.message":
512+
error instanceof Error ? error.message : String(error),
513+
},
514+
"Failed to report assistant status",
515+
);
516+
});
504517
const handoffMessages = await compactContextForHandoff(
505518
{
506519
conversationContext: input.conversationContext,

packages/junior/src/chat/runtime/agent-continue-runner.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import {
8383
retainRuntimeTurnContext,
8484
stripRuntimeTurnContext,
8585
} from "@/chat/pi/transcript";
86+
import { latestReportedProgress } from "@/chat/runtime/report-progress";
8687

8788
const AGENT_CONTINUE_LOCK_RETRY_DELAYS_MS = [250, 1_000, 2_000] as const;
8889

@@ -395,9 +396,17 @@ export async function continueSlackAgentRun(
395396
return false;
396397
}
397398

399+
const turnMessages =
400+
activeSessionRecord.turnStartMessageIndex === undefined
401+
? []
402+
: activeSessionRecord.piMessages.slice(
403+
activeSessionRecord.turnStartMessageIndex,
404+
);
405+
398406
return {
399407
messageText: userMessage.text,
400408
messageTs: getTurnUserSlackMessageTs(userMessage),
409+
initialStatus: latestReportedProgress(turnMessages),
401410
replyContext: {
402411
input: {
403412
conversationContext,

packages/junior/src/chat/runtime/reply-executor.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,7 +1018,7 @@ export function createReplyToThread(deps: ReplyExecutorDeps) {
10181018
channelId,
10191019
runId,
10201020
},
1021-
onCompactionStart: () => status.start(compactingStatus),
1021+
onCompactionStart: () => status.update(compactingStatus),
10221022
piMessages,
10231023
});
10241024
if (compaction.compacted) {
@@ -1077,7 +1077,7 @@ export function createReplyToThread(deps: ReplyExecutorDeps) {
10771077
}
10781078
}
10791079

1080-
status.start();
1080+
status.update();
10811081
const assistantTitleTask = maybeUpdateAssistantTitle({
10821082
assistantThreadContext,
10831083
assistantUserName: botConfig.userName,
@@ -1726,7 +1726,7 @@ export function createReplyToThread(deps: ReplyExecutorDeps) {
17261726
);
17271727
}
17281728
}
1729-
await status.stop();
1729+
await status.clear();
17301730
}
17311731
},
17321732
);

packages/junior/src/chat/runtime/report-progress.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { PiMessage } from "@/chat/pi/messages";
12
import type { AssistantStatusSpec } from "@/chat/slack/assistant-thread/status-render";
23

34
/** Convert a `reportProgress` tool payload into assistant status text. */
@@ -20,3 +21,42 @@ export function buildReportedProgressStatus(
2021

2122
return { text };
2223
}
24+
25+
/** Recover the latest explicit progress update from a resumable Pi transcript. */
26+
export function latestReportedProgress(
27+
messages: readonly PiMessage[],
28+
): AssistantStatusSpec | undefined {
29+
for (
30+
let messageIndex = messages.length - 1;
31+
messageIndex >= 0;
32+
messageIndex -= 1
33+
) {
34+
const message = messages[messageIndex] as {
35+
role?: unknown;
36+
content?: unknown;
37+
};
38+
if (message.role !== "assistant" || !Array.isArray(message.content)) {
39+
continue;
40+
}
41+
for (
42+
let partIndex = message.content.length - 1;
43+
partIndex >= 0;
44+
partIndex -= 1
45+
) {
46+
const part = message.content[partIndex];
47+
if (!part || typeof part !== "object") {
48+
continue;
49+
}
50+
const toolCall = part as {
51+
type?: unknown;
52+
name?: unknown;
53+
arguments?: unknown;
54+
};
55+
if (toolCall.type !== "toolCall" || toolCall.name !== "reportProgress") {
56+
continue;
57+
}
58+
return buildReportedProgressStatus(toolCall.arguments);
59+
}
60+
}
61+
return undefined;
62+
}

packages/junior/src/chat/runtime/slack-resume.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { persistThreadStateById } from "@/chat/runtime/thread-state";
2525
import {
2626
createSlackWebApiAssistantStatusSession,
2727
type AssistantStatusSession,
28+
type AssistantStatusSpec,
2829
} from "@/chat/slack/assistant-thread/status";
2930
import {
3031
buildSlackReplyFooter,
@@ -136,6 +137,7 @@ interface ResumeSlackTurnArgs {
136137
replyContext?: ResumeReplyContext;
137138
lockKey?: string;
138139
initialText?: string;
140+
initialStatus?: AssistantStatusSpec;
139141
agentRunner: AgentRunner;
140142
scheduleSessionCompletedPluginTasks?: (params: {
141143
conversationId: string;
@@ -406,7 +408,7 @@ export async function resumeSlackTurn(
406408
runArgs.initialText,
407409
);
408410
}
409-
status.start();
411+
status.update(runArgs.initialStatus);
410412

411413
const replyContext = createResumeReplyContext(runArgs, status);
412414
const replyPromise = runArgs.agentRunner.run(replyContext);
@@ -431,7 +433,7 @@ export async function resumeSlackTurn(
431433
if (outcome.status !== "completed") {
432434
// Expected pauses defer their handlers until the lock is released,
433435
// mirroring the failure path below.
434-
await status.stop();
436+
await status.clear();
435437
const onAuthPause = runArgs.onAuthPause;
436438
const onTimeoutPause = runArgs.onTimeoutPause;
437439
if (outcome.status === "awaiting_auth" && onAuthPause) {
@@ -471,7 +473,7 @@ export async function resumeSlackTurn(
471473
context: getResumeLogContext(runArgs, lockKey),
472474
});
473475

474-
await status.stop();
476+
await status.clear();
475477
const footer = buildSlackReplyFooter({
476478
conversationId: getResumeConversationId(runArgs, lockKey),
477479
});
@@ -541,7 +543,7 @@ export async function resumeSlackTurn(
541543
}
542544
}
543545
} catch (error) {
544-
await status.stop();
546+
await status.clear();
545547

546548
if (finalReplyDelivered) {
547549
postDeliveryCommitError = error;

packages/junior/src/chat/slack/assistant-thread/status-scheduler.ts

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ const STATUS_ROTATION_INTERVAL_MS = 30_000;
1212
export type TimerHandle = ReturnType<typeof setTimeout>;
1313

1414
export interface AssistantStatusSession {
15-
start: (status?: AssistantStatusSpec) => void;
16-
stop: () => Promise<void>;
17-
update: (status: AssistantStatusSpec) => void;
15+
/** Activate or replace the loading state; omit status to show generic loading. */
16+
update: (status?: AssistantStatusSpec) => void;
17+
/** Stop refresh scheduling and clear the loading state. */
18+
clear: () => Promise<void>;
1819
}
1920

2021
/**
@@ -59,7 +60,7 @@ export function createAssistantStatusScheduler(args: {
5960

6061
const enqueueStatusUpdate = (task: () => Promise<void>): Promise<void> => {
6162
// Status writes are best effort, but they still need strict ordering so a
62-
// slow "thinking" write cannot land after stop() already cleared the UI.
63+
// slow "thinking" write cannot land after clear() already cleared the UI.
6364
const request = inflightStatusUpdate
6465
.catch(() => undefined)
6566
.then(async () => {
@@ -172,17 +173,7 @@ export function createAssistantStatusScheduler(args: {
172173
};
173174

174175
return {
175-
start(status?: AssistantStatusSpec) {
176-
active = true;
177-
clearPending();
178-
if (status) {
179-
void postRenderedStatus(status);
180-
return;
181-
}
182-
currentKey = "initial";
183-
void postStatus(getInitialStatusText(), loadingMessages);
184-
},
185-
async stop() {
176+
async clear() {
186177
active = false;
187178
clearPending();
188179
if (rotationTimer) {
@@ -192,8 +183,22 @@ export function createAssistantStatusScheduler(args: {
192183
currentKey = "";
193184
await postStatus("");
194185
},
195-
update(status: AssistantStatusSpec) {
186+
update(status?: AssistantStatusSpec) {
196187
if (!active) {
188+
active = true;
189+
clearPending();
190+
if (status) {
191+
void postRenderedStatus(status);
192+
return;
193+
}
194+
currentKey = "initial";
195+
void postStatus(getInitialStatusText(), loadingMessages);
196+
return;
197+
}
198+
if (!status) {
199+
clearPending();
200+
currentKey = "initial";
201+
void postStatus(getInitialStatusText(), loadingMessages);
197202
return;
198203
}
199204
const presentation = renderAssistantStatus({

0 commit comments

Comments
 (0)