mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user