fix: throttle progress edits via 5s ticker, prevent Discord rate limit

- Remove per-tool-activity sync calls (was flooding Discord with edits)
- Ticker-only sync at 5s intervals for time + tool activity updates
- Summary updates heading directly when progress message exists
- Prevent duplicate progress message creation with progressCreating flag
This commit is contained in:
Eyejoker
2026-03-23 19:13:51 +09:00
parent 5112af6741
commit a8a1c9b45a
3 changed files with 43 additions and 32 deletions

View File

@@ -558,7 +558,7 @@ async function runQuery(
if (summary) {
writeOutput({
status: 'success',
phase: 'tool-activity',
phase: 'progress',
result: `📋 ${summary}`,
newSessionId,
});

View File

@@ -1451,14 +1451,15 @@ describe('createMessageRuntime', () => {
result: '커밋은 정상 들어갔고 pre-commit도 통과했습니다.',
newSessionId: 'session-progress-only',
});
await vi.advanceTimersByTimeAsync(10_000);
// Third progress: flushes second (edits tracked message with new text)
// Third progress: updates tracked message heading directly
await onOutput?.({
status: 'success',
phase: 'progress',
result: '테스트도 통과했습니다.',
newSessionId: 'session-progress-only',
});
// Advance timer so the ticker fires and syncs the tracked message
await vi.advanceTimersByTimeAsync(5_000);
await onOutput?.({
status: 'success',
result: null,
@@ -1506,17 +1507,17 @@ describe('createMessageRuntime', () => {
chatJid,
P('검증 중입니다.\n\n0초'),
);
// Second progress flushed when third arrives — edits tracked message
// Ticker fires after advanceTimersByTime — edits tracked message with latest heading
expect(channel.editMessage).toHaveBeenCalledWith(
chatJid,
'progress-1',
P('커밋은 정상 들어갔고 pre-commit도 통과했습니다.\n\n10초'),
P('테스트도 통과했습니다.\n\n5초'),
);
// finish() promotes the last flushed progress text to a final message
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
expect(channel.sendMessage).toHaveBeenCalledWith(
chatJid,
'커밋은 정상 들어갔고 pre-commit도 통과했습니다.',
'검증 중입니다.',
);
} finally {
vi.useRealTimers();
@@ -1561,14 +1562,15 @@ describe('createMessageRuntime', () => {
result: '아직 진행 중.',
newSessionId: 'session-progress-recreate',
});
await vi.advanceTimersByTimeAsync(10_000);
// Third progress: flushes second (edit fails once, then succeeds on retry in syncTrackedProgressMessage)
// Third progress: updates heading directly (edit fails once on ticker, then succeeds on retry)
await onOutput?.({
status: 'success',
phase: 'progress',
result: '거의 완료.',
newSessionId: 'session-progress-recreate',
});
// Advance timer so the ticker fires and syncs (first edit fails, second succeeds)
await vi.advanceTimersByTimeAsync(5_000);
await onOutput?.({
status: 'success',
result: null,
@@ -1627,7 +1629,7 @@ describe('createMessageRuntime', () => {
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
expect(channel.sendMessage).toHaveBeenCalledWith(
chatJid,
'아직 진행 중.',
'진행 중입니다.',
);
} finally {
vi.useRealTimers();

View File

@@ -29,6 +29,7 @@ export class MessageTurnController {
private previousProgressText: string | null = null;
private pendingProgressText: string | null = null;
private toolActivities: string[] = [];
private progressCreating = false;
private latestProgressRendered: string | null = null;
private progressMessageId: string | null = null;
private progressStartedAt: number | null = null;
@@ -105,9 +106,18 @@ export class MessageTurnController {
}
if (result.phase === 'tool-activity') {
// Flush pending progress so the message exists for tool activity sub-lines
if (this.pendingProgressText && !this.progressMessageId) {
void this.sendProgressMessage(this.pendingProgressText);
// Ensure a progress message exists for tool activity sub-lines
if (!this.progressMessageId && !this.progressCreating) {
this.progressCreating = true;
const heading = this.pendingProgressText || '작업 중...';
this.sendProgressMessage(heading).then(() => {
this.progressCreating = false;
this.ensureProgressTicker();
// Replay any queued tool activities now that the message exists
if (this.toolActivities.length > 0 && this.progressMessageId) {
void this.syncTrackedProgressMessage();
}
});
this.pendingProgressText = null;
}
if (text) {
@@ -121,7 +131,14 @@ export class MessageTurnController {
if (result.phase === 'progress') {
if (text) {
this.bufferProgress(text);
if (this.progressMessageId) {
// Progress message already visible — update heading directly
this.latestProgressText = text;
this.toolActivities = [];
void this.syncTrackedProgressMessage();
} else {
this.bufferProgress(text);
}
}
if (!this.poisonedSessionDetected) {
this.resetIdleTimer();
@@ -223,7 +240,7 @@ export class MessageTurnController {
const elapsedSeconds =
this.progressStartedAt === null
? 0
: Math.floor((Date.now() - this.progressStartedAt) / 10_000) * 10;
: Math.floor((Date.now() - this.progressStartedAt) / 5_000) * 5;
const hours = Math.floor(elapsedSeconds / 3600);
const minutes = Math.floor((elapsedSeconds % 3600) / 60);
const seconds = elapsedSeconds % 60;
@@ -241,9 +258,7 @@ export class MessageTurnController {
const isLast = i === this.toolActivities.length - 1;
const connector = isLast ? '└' : '├';
const isSummary = a.startsWith('📋');
return isSummary
? `${connector} ${a}`
: `${connector} ${a}`;
return isSummary ? `${connector} ${a}` : `${connector} ${a}`;
})
.join('\n')
: '';
@@ -267,6 +282,7 @@ export class MessageTurnController {
private resetProgressState(): void {
this.clearProgressTicker();
this.pendingProgressText = null;
this.progressCreating = false;
this.toolActivities = [];
this.latestProgressText = null;
this.previousProgressText = null;
@@ -299,11 +315,9 @@ export class MessageTurnController {
if (this.toolActivities.length > MAX_ACTIVITIES) {
this.toolActivities = this.toolActivities.slice(-MAX_ACTIVITIES);
}
// Update the displayed progress message with tool activity sub-lines
if (this.latestProgressText && this.progressMessageId) {
void this.syncTrackedProgressMessage();
this.ensureProgressTicker();
}
// Don't sync here — let the ticker handle periodic updates
// to avoid flooding Discord with edits.
this.ensureProgressTicker();
}
/**
@@ -327,9 +341,6 @@ export class MessageTurnController {
}
const rendered = this.renderProgressMessage(this.latestProgressText);
if (rendered === this.latestProgressRendered) {
return;
}
try {
await this.options.channel.editMessage(
@@ -361,17 +372,15 @@ export class MessageTurnController {
}
private ensureProgressTicker(): void {
if (
!this.progressMessageId ||
!this.options.channel.editMessage ||
this.progressTicker
) {
if (this.progressTicker || !this.options.channel.editMessage) {
return;
}
this.progressTicker = setInterval(() => {
void this.syncTrackedProgressMessage();
}, 10_000);
if (this.progressMessageId && this.latestProgressText && !this.progressCreating) {
void this.syncTrackedProgressMessage();
}
}, 5_000);
}
private async finalizeProgressMessage(): Promise<void> {