fix(web): close P3 chat re-review findings
This commit is contained in:
@@ -8,7 +8,13 @@ import {
|
||||
MAX_STREAM_CHARS,
|
||||
MAX_TOOLS,
|
||||
} from './limits';
|
||||
import { asConversationId, asFiniteNumber, asString, isRecord } from './runtime-guards';
|
||||
import {
|
||||
asConversationId,
|
||||
asFiniteNumber,
|
||||
asString,
|
||||
asStringArray,
|
||||
isRecord,
|
||||
} from './runtime-guards';
|
||||
import type {
|
||||
AgentEndPayload,
|
||||
AgentStartPayload,
|
||||
@@ -35,11 +41,64 @@ export interface ToolCallState {
|
||||
status: 'running' | 'success' | 'error' | 'anomaly';
|
||||
}
|
||||
|
||||
/** Appends `addition` to `existing`, keeping at most `max` characters by
|
||||
* dropping the oldest (leading) characters once the cap is exceeded. */
|
||||
function capAppendString(existing: string, addition: string, max: number): string {
|
||||
const next = existing + addition;
|
||||
return next.length > max ? next.slice(next.length - max) : next;
|
||||
/** Formats the visible marker prepended to a capped stream buffer once any
|
||||
* original characters have been dropped from it. Reconstructing the prior
|
||||
* tail (see `capAppendStream` below) always slices this exact computed
|
||||
* length off the front of the previous displayed value — it never scans
|
||||
* buffer content for marker-shaped text, so real streamed content that
|
||||
* happens to look like a marker can never be mistaken for one. */
|
||||
function formatTruncationMarker(dropped: number): string {
|
||||
return `…[truncated ${dropped} characters]…`;
|
||||
}
|
||||
|
||||
interface CappedStream {
|
||||
/** The full value to store/display — a plain tail when nothing has been
|
||||
* dropped yet, otherwise the marker followed by the retained tail. Always
|
||||
* bounded to at most `max` characters in total. */
|
||||
displayed: string;
|
||||
/** Total original stream characters dropped so far, cumulative across
|
||||
* every append — never reset while the buffer is still accumulating. */
|
||||
dropped: number;
|
||||
}
|
||||
|
||||
/** Appends `addition` to a stream buffer capped at `max` displayed
|
||||
* characters (marker included), honestly disclosing how many original
|
||||
* characters have been dropped so far rather than silently retaining only
|
||||
* the tail. `priorDisplayed`/`priorDropped` come from state; the marker (if
|
||||
* any) already present in `priorDisplayed` is stripped by the exact length
|
||||
* `formatTruncationMarker(priorDropped)` computes, not by pattern-matching. */
|
||||
function capAppendStream(
|
||||
priorDisplayed: string,
|
||||
priorDropped: number,
|
||||
addition: string,
|
||||
max: number,
|
||||
): CappedStream {
|
||||
const priorTail =
|
||||
priorDropped > 0
|
||||
? priorDisplayed.slice(formatTruncationMarker(priorDropped).length)
|
||||
: priorDisplayed;
|
||||
const combinedTail = priorTail + addition;
|
||||
|
||||
if (priorDropped === 0 && combinedTail.length <= max) {
|
||||
return { displayed: combinedTail, dropped: 0 };
|
||||
}
|
||||
|
||||
// The marker's own text grows (rarely) as `dropped`'s digit count grows,
|
||||
// which shrinks the budget left for the tail, which can in turn increase
|
||||
// `dropped` further — a handful of iterations is always enough to reach a
|
||||
// fixed point for any realistic character count.
|
||||
let dropped = priorDropped;
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
const budget = Math.max(0, max - formatTruncationMarker(dropped).length);
|
||||
const nextDropped = priorDropped + Math.max(0, combinedTail.length - budget);
|
||||
if (nextDropped === dropped) break;
|
||||
dropped = nextDropped;
|
||||
}
|
||||
|
||||
const marker = formatTruncationMarker(dropped);
|
||||
const budget = Math.max(0, max - marker.length);
|
||||
const tail = combinedTail.slice(Math.max(0, combinedTail.length - budget));
|
||||
return { displayed: marker + tail, dropped };
|
||||
}
|
||||
|
||||
/** Pushes `item` onto `arr`, dropping the oldest entries once `max` is exceeded. */
|
||||
@@ -72,6 +131,17 @@ function isUnrecoverableStartupFailure(state: ChatConnectionState): boolean {
|
||||
return state.conversationId === null && state.pendingSend;
|
||||
}
|
||||
|
||||
/** True when a scoped `agent:end`/`error` is a recognized-stale terminal: a
|
||||
* send is genuinely in flight (`sending`) but the current turn's own
|
||||
* ack/start has not armed `armedTurnToken` yet — so this event must be a
|
||||
* leftover from an earlier turn on the same conversation, not this one's own
|
||||
* terminal. Recognizing this must make the event a true no-op: it may not
|
||||
* finalize/clear transient state (text, thinking, streaming, error,
|
||||
* approvalRequestPending) that belongs to the still in-flight turn. */
|
||||
function isRecognizedStaleTerminal(state: ChatConnectionState): boolean {
|
||||
return state.sending && state.armedTurnToken !== state.turnToken;
|
||||
}
|
||||
|
||||
/** Deterministic, unique-per-event fallback id for a malformed `toolCallId`.
|
||||
* Sourced from a monotonically increasing counter carried in state (`toolSeq`)
|
||||
* rather than `tools.length`, so it stays collision-free even once `tools` is
|
||||
@@ -166,7 +236,14 @@ export interface ChatConnectionState {
|
||||
ack: MessageAckPayload | null;
|
||||
streaming: boolean;
|
||||
text: string;
|
||||
/** Total original `agent:text` characters dropped so far by the
|
||||
* MAX_STREAM_CHARS cap on `text` — tracked separately from `text` itself
|
||||
* so the honest cumulative count survives across multiple appends without
|
||||
* re-parsing any marker embedded in the displayed string. */
|
||||
textDroppedChars: number;
|
||||
thinking: string;
|
||||
/** Same accounting as `textDroppedChars`, for `thinking`. */
|
||||
thinkingDroppedChars: number;
|
||||
tools: ToolCallState[];
|
||||
usage: SessionUsagePayload | null;
|
||||
sessionInfo: SessionInfoPayload | null;
|
||||
@@ -189,6 +266,20 @@ export interface ChatConnectionState {
|
||||
* tools remain, so fallback ids stay unique across the MAX_TOOLS cap
|
||||
* boundary. */
|
||||
toolSeq: number;
|
||||
/** Monotonically increasing id for the current in-flight turn, minted at
|
||||
* local send time. Wire payloads (agent:end/error) carry only a
|
||||
* conversationId, not a turn-scoped correlation id — two different turns
|
||||
* on the SAME conversation are indistinguishable on the wire, so this
|
||||
* local counter is what actually tells them apart. */
|
||||
turnToken: number;
|
||||
/** The `turnToken` value (if any) that has been armed to accept a normal
|
||||
* terminal release, set only by an accepted (conversation-scoped)
|
||||
* message:ack or agent:start for the CURRENT token. A same-conversation
|
||||
* agent:end/error may only set `sending: false` when this equals
|
||||
* `turnToken` — otherwise it is a stale terminal event from an earlier
|
||||
* turn on the same conversation and must not unlock a still-in-flight
|
||||
* later turn. Fails closed: an unarmed token can never be unlocked. */
|
||||
armedTurnToken: number | null;
|
||||
}
|
||||
|
||||
export interface ChatConnectionActions {
|
||||
@@ -212,7 +303,9 @@ const initialState: ChatConnectionState = {
|
||||
ack: null,
|
||||
streaming: false,
|
||||
text: '',
|
||||
textDroppedChars: 0,
|
||||
thinking: '',
|
||||
thinkingDroppedChars: 0,
|
||||
tools: [],
|
||||
usage: null,
|
||||
sessionInfo: null,
|
||||
@@ -226,6 +319,8 @@ const initialState: ChatConnectionState = {
|
||||
messages: [],
|
||||
messageSeq: 0,
|
||||
toolSeq: 0,
|
||||
turnToken: 0,
|
||||
armedTurnToken: null,
|
||||
};
|
||||
|
||||
type Action =
|
||||
@@ -274,7 +369,14 @@ function resolveScopedConversation(
|
||||
return { active: true, state };
|
||||
}
|
||||
if (state.conversationId === null && state.pendingSend) {
|
||||
return { active: true, state: { ...state, conversationId, pendingSend: false } };
|
||||
// The very first scoped event for a brand-new conversation can only belong
|
||||
// to the currently in-flight turn — no prior same-conversation turn exists to
|
||||
// be confused with. Arm the current turnToken so a terminal event (error,
|
||||
// agent:end) arriving before ack/start can still properly release the send lock.
|
||||
return {
|
||||
active: true,
|
||||
state: { ...state, conversationId, pendingSend: false, armedTurnToken: state.turnToken },
|
||||
};
|
||||
}
|
||||
return { active: false, state: null };
|
||||
}
|
||||
@@ -311,37 +413,61 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
|
||||
conversationId,
|
||||
pendingSend: false,
|
||||
ack: sanitizeAck(payload, conversationId),
|
||||
// The very first scoped event ever received for a brand-new
|
||||
// conversation can only belong to the currently in-flight turn —
|
||||
// no prior same-conversation turn can exist to be confused with.
|
||||
armedTurnToken: state.turnToken,
|
||||
};
|
||||
}
|
||||
if (payload.conversationId !== state.conversationId) return state;
|
||||
return { ...state, ack: sanitizeAck(payload, state.conversationId) };
|
||||
return {
|
||||
...state,
|
||||
ack: sanitizeAck(payload, state.conversationId),
|
||||
armedTurnToken: state.turnToken,
|
||||
};
|
||||
}
|
||||
|
||||
case 'server/agent:start': {
|
||||
const resolved = resolveScopedConversation(state, action.payload.conversationId);
|
||||
if (!resolved.active) return state;
|
||||
return { ...resolved.state, streaming: true, text: '', thinking: '', tools: [], error: null };
|
||||
return {
|
||||
...resolved.state,
|
||||
streaming: true,
|
||||
text: '',
|
||||
thinking: '',
|
||||
textDroppedChars: 0,
|
||||
thinkingDroppedChars: 0,
|
||||
tools: [],
|
||||
error: null,
|
||||
armedTurnToken: resolved.state.turnToken,
|
||||
};
|
||||
}
|
||||
|
||||
case 'server/agent:text': {
|
||||
const resolved = resolveScopedConversation(state, action.payload.conversationId);
|
||||
if (!resolved.active) return state;
|
||||
return {
|
||||
...resolved.state,
|
||||
text: capAppendString(resolved.state.text, asString(action.payload.text), MAX_STREAM_CHARS),
|
||||
};
|
||||
const capped = capAppendStream(
|
||||
resolved.state.text,
|
||||
resolved.state.textDroppedChars,
|
||||
asString(action.payload.text),
|
||||
MAX_STREAM_CHARS,
|
||||
);
|
||||
return { ...resolved.state, text: capped.displayed, textDroppedChars: capped.dropped };
|
||||
}
|
||||
|
||||
case 'server/agent:thinking': {
|
||||
const resolved = resolveScopedConversation(state, action.payload.conversationId);
|
||||
if (!resolved.active) return state;
|
||||
const capped = capAppendStream(
|
||||
resolved.state.thinking,
|
||||
resolved.state.thinkingDroppedChars,
|
||||
asString(action.payload.text),
|
||||
MAX_STREAM_CHARS,
|
||||
);
|
||||
return {
|
||||
...resolved.state,
|
||||
thinking: capAppendString(
|
||||
resolved.state.thinking,
|
||||
asString(action.payload.text),
|
||||
MAX_STREAM_CHARS,
|
||||
),
|
||||
thinking: capped.displayed,
|
||||
thinkingDroppedChars: capped.dropped,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -413,12 +539,20 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
|
||||
sending: false,
|
||||
streaming: false,
|
||||
approvalRequestPending: false,
|
||||
armedTurnToken: null,
|
||||
error: CONVERSATION_START_FAILURE,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
const next = resolved.state;
|
||||
if (isRecognizedStaleTerminal(next)) {
|
||||
// A same-conversation agent:end recognized as stale must be a true
|
||||
// no-op — it must not finalize the stale turn's leftover
|
||||
// text/thinking into a message or reset streaming, both of which
|
||||
// legitimately belong to the still in-flight later turn.
|
||||
return next;
|
||||
}
|
||||
const hasContent = next.text.length > 0 || next.thinking.length > 0;
|
||||
const messages = hasContent
|
||||
? capPush(
|
||||
@@ -435,12 +569,23 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
|
||||
MAX_MESSAGES,
|
||||
)
|
||||
: next.messages;
|
||||
// The wire carries only conversationId, not a turn id — a stale
|
||||
// agent:end left over from an EARLIER turn on this SAME conversation
|
||||
// is indistinguishable from this turn's own by conversationId alone.
|
||||
// Only release `sending` when this turn's own accepted ack/start armed
|
||||
// the current token; otherwise this is treated as a stale echo and the
|
||||
// lock stays held (fail closed) so it cannot unlock a later, still
|
||||
// in-flight turn.
|
||||
const canRelease = next.armedTurnToken === next.turnToken;
|
||||
return {
|
||||
...next,
|
||||
streaming: false,
|
||||
sending: false,
|
||||
sending: canRelease ? false : next.sending,
|
||||
armedTurnToken: canRelease ? null : next.armedTurnToken,
|
||||
text: '',
|
||||
thinking: '',
|
||||
textDroppedChars: 0,
|
||||
thinkingDroppedChars: 0,
|
||||
usage: payload.usage ?? next.usage,
|
||||
messages,
|
||||
messageSeq: hasContent ? next.messageSeq + 1 : next.messageSeq,
|
||||
@@ -448,9 +593,23 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
|
||||
}
|
||||
|
||||
case 'server/session:info': {
|
||||
const resolved = resolveScopedConversation(state, action.payload.conversationId);
|
||||
const { payload } = action;
|
||||
const resolved = resolveScopedConversation(state, payload.conversationId);
|
||||
if (!resolved.active) return state;
|
||||
return { ...resolved.state, sessionInfo: action.payload };
|
||||
return {
|
||||
...resolved.state,
|
||||
sessionInfo: {
|
||||
...payload,
|
||||
// A hostile/malfunctioning gateway can flood this list; cap it
|
||||
// before it ever reaches state so a render site (e.g. the
|
||||
// thinking-level <select>) can never be forced to lay out an
|
||||
// unbounded number of options.
|
||||
availableThinkingLevels: asStringArray(payload.availableThinkingLevels).slice(
|
||||
0,
|
||||
MAX_MANIFEST_ITEMS,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'server/commands:manifest': {
|
||||
@@ -512,12 +671,24 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
|
||||
|
||||
case 'server/system:reload': {
|
||||
const { payload } = action;
|
||||
// Computed once and reused for both `systemReload` and `manifest` —
|
||||
// spreading the raw `payload` into `systemReload` first and only
|
||||
// capping the copy handed to `manifest` left the raw, uncapped
|
||||
// commands/skills sitting in `state.systemReload`. The sanitized
|
||||
// fields are placed after the spread below so they always win.
|
||||
const commands = capList<CommandDef>(payload.commands, MAX_MANIFEST_ITEMS);
|
||||
const skills = capList<SkillCommandDef>(payload.skills, MAX_MANIFEST_ITEMS);
|
||||
return {
|
||||
...state,
|
||||
systemReload: { ...payload, message: asString(payload.message, 'Commands reloaded.') },
|
||||
systemReload: {
|
||||
...payload,
|
||||
commands,
|
||||
skills,
|
||||
message: asString(payload.message, 'Commands reloaded.'),
|
||||
},
|
||||
manifest: {
|
||||
commands: capList<CommandDef>(payload.commands, MAX_MANIFEST_ITEMS),
|
||||
skills: capList<SkillCommandDef>(payload.skills, MAX_MANIFEST_ITEMS),
|
||||
commands,
|
||||
skills,
|
||||
version: state.manifest?.version ?? 0,
|
||||
},
|
||||
};
|
||||
@@ -534,16 +705,31 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
|
||||
sending: false,
|
||||
streaming: false,
|
||||
approvalRequestPending: false,
|
||||
armedTurnToken: null,
|
||||
error: CONVERSATION_START_FAILURE,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
const next = resolved.state;
|
||||
if (isRecognizedStaleTerminal(next)) {
|
||||
// Same true-no-op guard as `server/agent:end` above: a
|
||||
// same-conversation error recognized as stale must not display/store
|
||||
// its message, touch streaming, or clear approvalRequestPending —
|
||||
// doing so would re-arm the approve UI for a request that belongs to
|
||||
// the still in-flight later turn while the first remains outstanding.
|
||||
return next;
|
||||
}
|
||||
// Same fail-closed turn-token guard as `server/agent:end` above: a
|
||||
// same-conversation error left over from an earlier, already-finished
|
||||
// turn must not release a later turn's still-in-flight send lock.
|
||||
const canRelease = next.armedTurnToken === next.turnToken;
|
||||
return {
|
||||
...resolved.state,
|
||||
...next,
|
||||
error: asString(payload.error, 'An error occurred.'),
|
||||
streaming: false,
|
||||
sending: false,
|
||||
sending: canRelease ? false : next.sending,
|
||||
armedTurnToken: canRelease ? null : next.armedTurnToken,
|
||||
// A turn-scoped error also invalidates any approval request awaiting
|
||||
// a response — the gateway that just errored is unlikely to still
|
||||
// answer it, and the synchronous approveLockRef mirrors this field.
|
||||
@@ -566,6 +752,12 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
|
||||
messageSeq: state.messageSeq + 1,
|
||||
error: null,
|
||||
sending: true,
|
||||
// Sending a new turn mints a fresh token and immediately clears
|
||||
// terminal eligibility — only THIS turn's own accepted ack/start may
|
||||
// arm it, so a same-conversation terminal left over from the turn
|
||||
// that just finished can never be mistaken for this one's.
|
||||
turnToken: state.turnToken + 1,
|
||||
armedTurnToken: null,
|
||||
pendingSend: state.conversationId === null ? true : state.pendingSend,
|
||||
};
|
||||
}
|
||||
@@ -579,6 +771,7 @@ function reduce(state: ChatConnectionState, action: Action): ChatConnectionState
|
||||
sending: false,
|
||||
pendingSend: false,
|
||||
approvalRequestPending: false,
|
||||
armedTurnToken: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -766,12 +959,24 @@ export function useChatConnection(): ChatConnectionValue {
|
||||
if (approval?.success !== true) return;
|
||||
if (typeof approval.approvalId !== 'string' || approval.approvalId.length === 0) return;
|
||||
if (!pendingApproval || pendingApproval.command !== approval.command) return;
|
||||
if (executedApprovalIds.current.has(approval.approvalId)) return;
|
||||
if (executedApprovalIds.current.has(approval.approvalId)) {
|
||||
dispatch({ type: 'local/consume-approval' });
|
||||
return;
|
||||
}
|
||||
if (executedApprovalIds.current.size >= MAX_EXECUTED_APPROVAL_IDS) {
|
||||
// Bounded dedup set: evict the oldest entry (Sets iterate in
|
||||
// insertion order) so this can never grow without limit.
|
||||
const oldest = executedApprovalIds.current.values().next().value;
|
||||
if (oldest !== undefined) executedApprovalIds.current.delete(oldest);
|
||||
// Security tradeoff, chosen deliberately: this dedup set never
|
||||
// evicts. Evicting the oldest entry to make room (the old behavior)
|
||||
// would let a replay of that forgotten ID execute again once it
|
||||
// scrolled out of the set — a false negative that lets a privileged
|
||||
// command run twice. Once the set is full, every *unseen* approval
|
||||
// is denied instead. The mounted hook remains fail-closed until
|
||||
// unmounted (lifecycle reset), not recoverable by re-approving — a
|
||||
// false positive/availability cost but a genuine replay of any ID
|
||||
// ever seen by this hook can never execute a second time. Consuming
|
||||
// (rather than silently no-op'ing) releases the UI lock so the
|
||||
// denial is visible/recoverable by remounting.
|
||||
dispatch({ type: 'local/consume-approval' });
|
||||
return;
|
||||
}
|
||||
executedApprovalIds.current.add(approval.approvalId);
|
||||
const socket = getSocket();
|
||||
|
||||
Reference in New Issue
Block a user