fix(desktop): prevent completed typing resurrection

Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-23 16:29:25 -07:00
committed by Carl
parent 6898defb6c
commit 5c5e60b4e9
2 changed files with 74 additions and 40 deletions

View File

@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { recordTypingCompletion } from "./useChannelTyping.ts";
import {
clearTypingStateForCompletion,
recordTypingCompletion,
} from "./useChannelTyping.ts";
const TYPING_KEY = "agent:thread";
const NOW = 2_000_000_000_000;
@@ -14,17 +17,12 @@ function makeState() {
};
}
function record(
state,
createdAt,
{ hasActiveTyping = true, now = NOW, typingCreatedAt = createdAt } = {},
) {
function record(state, createdAt, { now = NOW } = {}) {
return recordTypingCompletion({
createdAt,
latestMessageCreatedAtByPubkey: state.latestMessageCreatedAtByPubkey,
now,
suppressUntilByPubkey: state.suppressUntilByPubkey,
typingCreatedAt: hasActiveTyping ? typingCreatedAt : undefined,
typingKey: TYPING_KEY,
});
}
@@ -33,7 +31,7 @@ test("duplicate full-array replay does not reprocess completion events", () => {
const state = makeState();
assert.equal(record(state, NOW_SECONDS), true);
assert.equal(record(state, NOW_SECONDS), false);
assert.equal(record(state, NOW_SECONDS, { now: NOW + 1_000 }), false);
assert.deepEqual(state.latestMessageCreatedAtByPubkey, {
[TYPING_KEY]: { createdAt: NOW_SECONDS, observedAt: NOW },
});
@@ -66,17 +64,18 @@ test("older-then-newer completion arrival advances the watermark", () => {
assert.equal(state.suppressUntilByPubkey[TYPING_KEY], NOW + 2_000);
});
test("historical thread load records ordering but creates no fresh suppression", () => {
test("historical first observation arms one bounded suppression for trailing ticks", () => {
const state = makeState();
const historicalCreatedAt = NOW_SECONDS - 60;
assert.equal(
record(state, NOW_SECONDS - 60, { hasActiveTyping: false }),
false,
);
assert.equal(record(state, historicalCreatedAt), true);
assert.equal(record(state, historicalCreatedAt, { now: NOW + 1_000 }), false);
assert.deepEqual(state.latestMessageCreatedAtByPubkey, {
[TYPING_KEY]: { createdAt: NOW_SECONDS - 60, observedAt: NOW },
[TYPING_KEY]: { createdAt: historicalCreatedAt, observedAt: NOW },
});
assert.deepEqual(state.suppressUntilByPubkey, {
[TYPING_KEY]: NOW + 2_000,
});
assert.deepEqual(state.suppressUntilByPubkey, {});
});
test("lagging agent completion clears typing for a matching agent-clock event", () => {
@@ -99,14 +98,43 @@ test("ahead agent completion keeps suppression desktop-local", () => {
});
});
test("message older than active typing advances ordering without clearing", () => {
const state = makeState();
test("queued newer typing survives an older completion clear", () => {
const typingStateAfterQueuedTick = {
[TYPING_KEY]: {
createdAt: NOW_SECONDS + 1,
expiresAt: NOW + 8_000,
firstSeenAt: NOW,
pubkey: "agent",
threadHeadId: "thread",
},
};
assert.equal(
record(state, NOW_SECONDS - 60, { typingCreatedAt: NOW_SECONDS }),
false,
clearTypingStateForCompletion(
typingStateAfterQueuedTick,
TYPING_KEY,
NOW_SECONDS,
NOW,
),
typingStateAfterQueuedTick,
);
});
test("completion clears matching or older typing state", () => {
const typingState = {
[TYPING_KEY]: {
createdAt: NOW_SECONDS,
expiresAt: NOW + 8_000,
firstSeenAt: NOW,
pubkey: "agent",
threadHeadId: "thread",
},
};
assert.deepEqual(
clearTypingStateForCompletion(typingState, TYPING_KEY, NOW_SECONDS, NOW),
{},
);
assert.deepEqual(state.suppressUntilByPubkey, {});
});
test("watermarks expire by desktop observation time, not agent clock", () => {

View File

@@ -43,22 +43,20 @@ const TYPING_POST_MESSAGE_SUPPRESS_MS = 2_000;
* agent's clock domain; retention and suppression use the observing desktop's
* clock so host skew cannot disable or stretch typing cleanup.
*
* Returns whether matching typing state should be cleared. Exported so replay,
* out-of-order, and clock-skew behavior stays unit-tested.
* Returns whether this completion newly advanced the scope watermark. Exported so
* replay, out-of-order, and clock-skew behavior stays unit-tested.
*/
export function recordTypingCompletion({
createdAt,
latestMessageCreatedAtByPubkey,
now = Date.now(),
suppressUntilByPubkey,
typingCreatedAt,
typingKey,
}: {
createdAt: number;
latestMessageCreatedAtByPubkey: TypingCompletionWatermarks;
now?: number;
suppressUntilByPubkey: TypingSuppressionDeadlines;
typingCreatedAt?: number;
typingKey: string;
}) {
for (const [key, watermark] of Object.entries(
@@ -80,9 +78,8 @@ export function recordTypingCompletion({
return false;
}
latestMessageCreatedAtByPubkey[typingKey] = { createdAt, observedAt: now };
if (typingCreatedAt === undefined || createdAt < typingCreatedAt) {
return false;
}
// Arm even without visible typing: the harness can publish a trailing tick
// after its reply, and that tick must not resurrect a completed-turn pill.
suppressUntilByPubkey[typingKey] = now + TYPING_POST_MESSAGE_SUPPRESS_MS;
return true;
}
@@ -103,6 +100,23 @@ function pruneTypingState(state: TypingState, now = Date.now()) {
return changed ? next : state;
}
export function clearTypingStateForCompletion(
state: TypingState,
typingKey: string,
completionCreatedAt: number,
now = Date.now(),
) {
const next = pruneTypingState(state, now);
const typingEntry = next[typingKey];
if (!typingEntry || completionCreatedAt < typingEntry.createdAt) {
return next;
}
const updated = { ...next };
delete updated[typingKey];
return updated;
}
function isTypingCompletionEvent(event: RelayEvent | null | undefined) {
if (!event) {
return false;
@@ -222,27 +236,19 @@ export function useChannelTyping(
}).toLowerCase();
const threadHeadId = getTypingScopeId(event);
const typingKey = getTypingStateKey(authorPubkey, threadHeadId);
const shouldClearTyping = recordTypingCompletion({
const isNewCompletion = recordTypingCompletion({
createdAt: event.created_at,
latestMessageCreatedAtByPubkey: latestMessageCreatedAtByPubkeyRef.current,
suppressUntilByPubkey: typingSuppressUntilByPubkeyRef.current,
typingCreatedAt: typingByPubkey[typingKey]?.createdAt,
typingKey,
});
if (!shouldClearTyping) {
if (!isNewCompletion) {
return;
}
setTypingByPubkey((current) => {
const next = pruneTypingState(current);
if (!(typingKey in next)) {
return next;
}
const updated = { ...next };
delete updated[typingKey];
return updated;
});
setTypingByPubkey((current) =>
clearTypingStateForCompletion(current, typingKey, event.created_at),
);
});
useEffect(() => {