Harden terminal streaming and worktree setup flow
This commit is contained in:
@@ -611,7 +611,7 @@ export function AgentInputArea({
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const cancelButton = isAgentRunning && !hasSendableContent ? (
|
const cancelButton = isAgentRunning && !hasSendableContent && !isProcessing ? (
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
onPress={handleCancelAgent}
|
onPress={handleCancelAgent}
|
||||||
@@ -755,6 +755,7 @@ export function AgentInputArea({
|
|||||||
voiceAgentId={agentId}
|
voiceAgentId={agentId}
|
||||||
isAgentRunning={isAgentRunning}
|
isAgentRunning={isAgentRunning}
|
||||||
onQueue={handleQueue}
|
onQueue={handleQueue}
|
||||||
|
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
||||||
onKeyPress={handleCommandKeyPress}
|
onKeyPress={handleCommandKeyPress}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export interface MessageInputProps {
|
|||||||
isAgentRunning?: boolean;
|
isAgentRunning?: boolean;
|
||||||
/** Callback for queue button when agent is running */
|
/** Callback for queue button when agent is running */
|
||||||
onQueue?: (payload: MessagePayload) => void;
|
onQueue?: (payload: MessagePayload) => void;
|
||||||
|
/** Optional handler used when submit button is in loading state. */
|
||||||
|
onSubmitLoadingPress?: () => void;
|
||||||
/** Intercept key press events before default handling. Return true to prevent default. */
|
/** Intercept key press events before default handling. Return true to prevent default. */
|
||||||
onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean;
|
onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean;
|
||||||
}
|
}
|
||||||
@@ -132,6 +134,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
voiceAgentId,
|
voiceAgentId,
|
||||||
isAgentRunning = false,
|
isAgentRunning = false,
|
||||||
onQueue,
|
onQueue,
|
||||||
|
onSubmitLoadingPress,
|
||||||
onKeyPress: onKeyPressCallback,
|
onKeyPress: onKeyPressCallback,
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
@@ -576,6 +579,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
const hasImages = images.length > 0;
|
const hasImages = images.length > 0;
|
||||||
const hasSendableContent = value.trim().length > 0 || hasImages;
|
const hasSendableContent = value.trim().length > 0 || hasImages;
|
||||||
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
|
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
|
||||||
|
const canPressLoadingButton =
|
||||||
|
isSubmitLoading && typeof onSubmitLoadingPress === "function";
|
||||||
|
const isSendButtonDisabled =
|
||||||
|
disabled ||
|
||||||
|
(!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
|
||||||
|
const submitAccessibilityLabel = canPressLoadingButton
|
||||||
|
? "Interrupt agent"
|
||||||
|
: isAgentRunning
|
||||||
|
? "Send and interrupt"
|
||||||
|
: "Send message";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container} testID="message-input-root">
|
<View style={styles.container} testID="message-input-root">
|
||||||
@@ -724,7 +737,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{rightContent}
|
{rightContent}
|
||||||
{shouldShowSendButton && isAgentRunning && onQueue && (
|
{hasSendableContent && isAgentRunning && onQueue && (
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
onPress={handleQueueMessage}
|
onPress={handleQueueMessage}
|
||||||
@@ -748,22 +761,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
)}
|
)}
|
||||||
{shouldShowSendButton && (
|
{shouldShowSendButton && (
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
onPress={handleSendMessage}
|
onPress={
|
||||||
disabled={
|
canPressLoadingButton ? onSubmitLoadingPress : handleSendMessage
|
||||||
isSubmitDisabled ||
|
|
||||||
isSubmitLoading ||
|
|
||||||
disabled
|
|
||||||
}
|
}
|
||||||
accessibilityLabel={isAgentRunning ? "Send and interrupt" : "Send message"}
|
disabled={isSendButtonDisabled}
|
||||||
accessibilityRole="button"
|
accessibilityLabel={submitAccessibilityLabel}
|
||||||
style={[
|
accessibilityRole="button"
|
||||||
styles.sendButton,
|
style={[
|
||||||
(isSubmitDisabled ||
|
styles.sendButton,
|
||||||
isSubmitLoading ||
|
isSendButtonDisabled && styles.buttonDisabled,
|
||||||
disabled) &&
|
]}
|
||||||
styles.buttonDisabled,
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
{isSubmitLoading ? (
|
{isSubmitLoading ? (
|
||||||
<ActivityIndicator size="small" color="white" />
|
<ActivityIndicator size="small" color="white" />
|
||||||
|
|||||||
@@ -1032,17 +1032,19 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
agentTitle: {
|
agentTitle: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
opacity: 0.85,
|
opacity: 1,
|
||||||
},
|
},
|
||||||
agentTitleInactive: {
|
agentTitleInactive: {
|
||||||
color: theme.colors.foregroundMuted,
|
opacity: 0.8,
|
||||||
},
|
},
|
||||||
agentTitleSelected: {
|
agentTitleSelected: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
},
|
},
|
||||||
relativeTimeText: {
|
relativeTimeText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
|
opacity: 0.78,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
},
|
},
|
||||||
shortcutBadge: {
|
shortcutBadge: {
|
||||||
@@ -1084,7 +1086,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
agentMetaProjectName: {
|
agentMetaProjectName: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foreground,
|
||||||
|
opacity: 0.78,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
@@ -1101,7 +1104,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderColor: theme.colors.borderAccent,
|
borderColor: theme.colors.borderAccent,
|
||||||
},
|
},
|
||||||
agentMetaBranchText: {
|
agentMetaBranchText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foreground,
|
||||||
|
opacity: 0.76,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
},
|
},
|
||||||
selectionBar: {
|
selectionBar: {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ interface TerminalEmulatorProps {
|
|||||||
meta: boolean;
|
meta: boolean;
|
||||||
}) => Promise<void> | void;
|
}) => Promise<void> | void;
|
||||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||||
|
onOutputChunkConsumed?: (sequence: number) => Promise<void> | void;
|
||||||
pendingModifiers?: PendingTerminalModifiers;
|
pendingModifiers?: PendingTerminalModifiers;
|
||||||
focusRequestToken?: number;
|
focusRequestToken?: number;
|
||||||
}
|
}
|
||||||
@@ -47,6 +48,7 @@ export default function TerminalEmulator({
|
|||||||
onResize,
|
onResize,
|
||||||
onTerminalKey,
|
onTerminalKey,
|
||||||
onPendingModifiersConsumed,
|
onPendingModifiersConsumed,
|
||||||
|
onOutputChunkConsumed,
|
||||||
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
||||||
focusRequestToken = 0,
|
focusRequestToken = 0,
|
||||||
}: TerminalEmulatorProps) {
|
}: TerminalEmulatorProps) {
|
||||||
@@ -108,19 +110,30 @@ export default function TerminalEmulator({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const runtime = runtimeRef.current;
|
const runtime = runtimeRef.current;
|
||||||
if (!runtime) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outputChunkSequence <= 0) {
|
if (outputChunkSequence <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (outputChunkText.length === 0) {
|
|
||||||
runtime.clear();
|
if (!runtime) {
|
||||||
|
onOutputChunkConsumed?.(outputChunkSequence);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
runtime.write({ text: outputChunkText });
|
|
||||||
}, [outputChunkSequence, outputChunkText]);
|
if (outputChunkText.length === 0) {
|
||||||
|
runtime.clear({
|
||||||
|
onCommitted: () => {
|
||||||
|
onOutputChunkConsumed?.(outputChunkSequence);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runtime.write({
|
||||||
|
text: outputChunkText,
|
||||||
|
onCommitted: () => {
|
||||||
|
onOutputChunkConsumed?.(outputChunkSequence);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [onOutputChunkConsumed, outputChunkSequence, outputChunkText]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusRequestToken <= 0) {
|
if (focusRequestToken <= 0) {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
TerminalOutputPump,
|
TerminalOutputPump,
|
||||||
type TerminalOutputChunk,
|
type TerminalOutputChunk,
|
||||||
} from "@/terminal/runtime/terminal-output-pump";
|
} from "@/terminal/runtime/terminal-output-pump";
|
||||||
|
import { TerminalOutputDeliveryQueue } from "@/terminal/runtime/terminal-output-delivery-queue";
|
||||||
import {
|
import {
|
||||||
TerminalStreamController,
|
TerminalStreamController,
|
||||||
type TerminalStreamControllerStatus,
|
type TerminalStreamControllerStatus,
|
||||||
@@ -72,6 +73,22 @@ type TerminalOutputChunkState = {
|
|||||||
text: string;
|
text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PendingTerminalInput =
|
||||||
|
| {
|
||||||
|
type: "data";
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "key";
|
||||||
|
input: {
|
||||||
|
key: string;
|
||||||
|
ctrl: boolean;
|
||||||
|
shift: boolean;
|
||||||
|
alt: boolean;
|
||||||
|
meta?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const EMPTY_MODIFIERS: ModifierState = {
|
const EMPTY_MODIFIERS: ModifierState = {
|
||||||
ctrl: false,
|
ctrl: false,
|
||||||
shift: false,
|
shift: false,
|
||||||
@@ -121,6 +138,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||||
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
||||||
const outputPumpRef = useRef<TerminalOutputPump | null>(null);
|
const outputPumpRef = useRef<TerminalOutputPump | null>(null);
|
||||||
|
const outputDeliveryQueueRef = useRef<TerminalOutputDeliveryQueue | null>(null);
|
||||||
|
|
||||||
const [selectedTerminalId, setSelectedTerminalId] = useState<string | null>(null);
|
const [selectedTerminalId, setSelectedTerminalId] = useState<string | null>(null);
|
||||||
const [selectedOutputChunk, setSelectedOutputChunk] = useState<TerminalOutputChunkState>({
|
const [selectedOutputChunk, setSelectedOutputChunk] = useState<TerminalOutputChunkState>({
|
||||||
@@ -142,16 +160,33 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
);
|
);
|
||||||
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
||||||
|
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectedTerminalIdRef.current = selectedTerminalId;
|
selectedTerminalIdRef.current = selectedTerminalId;
|
||||||
}, [selectedTerminalId]);
|
}, [selectedTerminalId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const outputDeliveryQueue = new TerminalOutputDeliveryQueue({
|
||||||
|
onDeliver: (chunk) => {
|
||||||
|
setSelectedOutputChunk(chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
outputDeliveryQueueRef.current = outputDeliveryQueue;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (outputDeliveryQueueRef.current === outputDeliveryQueue) {
|
||||||
|
outputDeliveryQueueRef.current = null;
|
||||||
|
}
|
||||||
|
outputDeliveryQueue.reset();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const outputPump = new TerminalOutputPump({
|
const outputPump = new TerminalOutputPump({
|
||||||
maxOutputChars: MAX_OUTPUT_CHARS,
|
maxOutputChars: MAX_OUTPUT_CHARS,
|
||||||
onSelectedOutputChunk: (chunk: TerminalOutputChunk) => {
|
onSelectedOutputChunk: (chunk: TerminalOutputChunk) => {
|
||||||
setSelectedOutputChunk(chunk);
|
outputDeliveryQueueRef.current?.enqueue(chunk);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
outputPumpRef.current = outputPump;
|
outputPumpRef.current = outputPump;
|
||||||
@@ -405,6 +440,9 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
}, [client, handleStreamControllerStatus, isConnected]);
|
}, [client, handleStreamControllerStatus, isConnected]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
outputDeliveryQueueRef.current?.reset();
|
||||||
|
pendingTerminalInputRef.current = [];
|
||||||
|
setSelectedOutputChunk({ sequence: 0, text: "" });
|
||||||
outputPumpRef.current?.setSelectedTerminal({
|
outputPumpRef.current?.setSelectedTerminal({
|
||||||
terminalId: selectedTerminalId,
|
terminalId: selectedTerminalId,
|
||||||
});
|
});
|
||||||
@@ -422,6 +460,9 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
activeStream && activeStream.terminalId === selectedTerminalId
|
activeStream && activeStream.terminalId === selectedTerminalId
|
||||||
? activeStream.streamId
|
? activeStream.streamId
|
||||||
: null;
|
: null;
|
||||||
|
const getCurrentActiveStreamId = useCallback(() => {
|
||||||
|
return streamControllerRef.current?.getActiveStreamId() ?? null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const selectedTerminal = useMemo(
|
const selectedTerminal = useMemo(
|
||||||
() => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null,
|
() => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null,
|
||||||
@@ -431,6 +472,50 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
createTerminalMutation.mutate();
|
createTerminalMutation.mutate();
|
||||||
}, [createTerminalMutation]);
|
}, [createTerminalMutation]);
|
||||||
|
|
||||||
|
const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
|
||||||
|
const queue = pendingTerminalInputRef.current;
|
||||||
|
queue.push(entry);
|
||||||
|
if (queue.length > 512) {
|
||||||
|
queue.splice(0, queue.length - 512);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const flushPendingTerminalInput = useCallback(() => {
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentStreamId = getCurrentActiveStreamId();
|
||||||
|
if (currentStreamId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const queue = pendingTerminalInputRef.current;
|
||||||
|
if (queue.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = queue.splice(0, queue.length);
|
||||||
|
terminalDebugLog({
|
||||||
|
scope: "terminal-pane",
|
||||||
|
event: "input:queue:flush",
|
||||||
|
details: {
|
||||||
|
activeStreamId: currentStreamId,
|
||||||
|
count: pending.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const entry of pending) {
|
||||||
|
if (entry.type === "data") {
|
||||||
|
client.sendTerminalStreamInput(currentStreamId, entry.data);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
client.sendTerminalStreamKey(currentStreamId, entry.input);
|
||||||
|
}
|
||||||
|
}, [client, getCurrentActiveStreamId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
flushPendingTerminalInput();
|
||||||
|
}, [activeStreamId, flushPendingTerminalInput]);
|
||||||
|
|
||||||
const handleCloseTerminal = useCallback(
|
const handleCloseTerminal = useCallback(
|
||||||
(terminalId: string) => {
|
(terminalId: string) => {
|
||||||
if (
|
if (
|
||||||
@@ -462,19 +547,30 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
meta?: boolean;
|
meta?: boolean;
|
||||||
}
|
}
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (!client || activeStreamId === null) {
|
const currentStreamId = getCurrentActiveStreamId();
|
||||||
|
if (!client || currentStreamId === null) {
|
||||||
terminalDebugLog({
|
terminalDebugLog({
|
||||||
scope: "terminal-pane",
|
scope: "terminal-pane",
|
||||||
event: "input:key:drop-no-stream",
|
event: "input:key:queue-no-stream",
|
||||||
details: {
|
details: {
|
||||||
key: input.key,
|
key: input.key,
|
||||||
ctrl: input.ctrl,
|
ctrl: input.ctrl,
|
||||||
shift: input.shift,
|
shift: input.shift,
|
||||||
alt: input.alt,
|
alt: input.alt,
|
||||||
activeStreamId,
|
activeStreamId: currentStreamId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return false;
|
enqueuePendingTerminalInput({
|
||||||
|
type: "key",
|
||||||
|
input: {
|
||||||
|
key: normalizeTerminalTransportKey(input.key),
|
||||||
|
ctrl: input.ctrl,
|
||||||
|
shift: input.shift,
|
||||||
|
alt: input.alt,
|
||||||
|
meta: input.meta,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedKey = normalizeTerminalTransportKey(input.key);
|
const normalizedKey = normalizeTerminalTransportKey(input.key);
|
||||||
@@ -486,10 +582,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
ctrl: input.ctrl,
|
ctrl: input.ctrl,
|
||||||
shift: input.shift,
|
shift: input.shift,
|
||||||
alt: input.alt,
|
alt: input.alt,
|
||||||
activeStreamId,
|
activeStreamId: currentStreamId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
client.sendTerminalStreamKey(activeStreamId, {
|
client.sendTerminalStreamKey(currentStreamId, {
|
||||||
key: normalizedKey,
|
key: normalizedKey,
|
||||||
ctrl: input.ctrl,
|
ctrl: input.ctrl,
|
||||||
shift: input.shift,
|
shift: input.shift,
|
||||||
@@ -498,7 +594,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[client, activeStreamId]
|
[client, enqueuePendingTerminalInput, getCurrentActiveStreamId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTerminalData = useCallback(
|
const handleTerminalData = useCallback(
|
||||||
@@ -506,13 +602,14 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const currentStreamId = getCurrentActiveStreamId();
|
||||||
terminalDebugLog({
|
terminalDebugLog({
|
||||||
scope: "terminal-pane",
|
scope: "terminal-pane",
|
||||||
event: "input:data:received",
|
event: "input:data:received",
|
||||||
details: {
|
details: {
|
||||||
length: data.length,
|
length: data.length,
|
||||||
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
|
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
|
||||||
activeStreamId,
|
activeStreamId: currentStreamId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -541,15 +638,19 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!client || activeStreamId === null) {
|
if (!client || currentStreamId === null) {
|
||||||
terminalDebugLog({
|
terminalDebugLog({
|
||||||
scope: "terminal-pane",
|
scope: "terminal-pane",
|
||||||
event: "input:data:drop-no-stream",
|
event: "input:data:queue-no-stream",
|
||||||
details: {
|
details: {
|
||||||
length: data.length,
|
length: data.length,
|
||||||
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
|
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
enqueuePendingTerminalInput({
|
||||||
|
type: "data",
|
||||||
|
data,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
terminalDebugLog({
|
terminalDebugLog({
|
||||||
@@ -558,19 +659,20 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
details: {
|
details: {
|
||||||
length: data.length,
|
length: data.length,
|
||||||
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
|
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
|
||||||
activeStreamId,
|
activeStreamId: currentStreamId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
client.sendTerminalStreamInput(activeStreamId, data);
|
client.sendTerminalStreamInput(currentStreamId, data);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
activeStreamId,
|
|
||||||
clearPendingModifiers,
|
clearPendingModifiers,
|
||||||
client,
|
client,
|
||||||
|
getCurrentActiveStreamId,
|
||||||
modifiers.alt,
|
modifiers.alt,
|
||||||
modifiers.ctrl,
|
modifiers.ctrl,
|
||||||
modifiers.shift,
|
modifiers.shift,
|
||||||
sendTerminalKey,
|
sendTerminalKey,
|
||||||
|
enqueuePendingTerminalInput,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -626,6 +728,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
clearPendingModifiers();
|
clearPendingModifiers();
|
||||||
}, [clearPendingModifiers]);
|
}, [clearPendingModifiers]);
|
||||||
|
|
||||||
|
const handleOutputChunkConsumed = useCallback((sequence: number) => {
|
||||||
|
outputDeliveryQueueRef.current?.consume({ sequence });
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toggleModifier = useCallback(
|
const toggleModifier = useCallback(
|
||||||
(modifier: keyof ModifierState) => {
|
(modifier: keyof ModifierState) => {
|
||||||
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
|
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
|
||||||
@@ -808,6 +914,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
onResize={handleTerminalResize}
|
onResize={handleTerminalResize}
|
||||||
onTerminalKey={handleTerminalKey}
|
onTerminalKey={handleTerminalKey}
|
||||||
onPendingModifiersConsumed={handlePendingModifiersConsumed}
|
onPendingModifiersConsumed={handlePendingModifiersConsumed}
|
||||||
|
onOutputChunkConsumed={handleOutputChunkConsumed}
|
||||||
pendingModifiers={modifiers}
|
pendingModifiers={modifiers}
|
||||||
focusRequestToken={focusRequestToken}
|
focusRequestToken={focusRequestToken}
|
||||||
/>
|
/>
|
||||||
@@ -819,7 +926,11 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isAttaching ? (
|
{isAttaching ? (
|
||||||
<View style={styles.attachOverlay} testID="terminal-attach-loading">
|
<View
|
||||||
|
style={styles.attachOverlay}
|
||||||
|
pointerEvents="none"
|
||||||
|
testID="terminal-attach-loading"
|
||||||
|
>
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
|
||||||
|
|
||||||
|
type StubTerminal = {
|
||||||
|
write: (text: string, callback?: () => void) => void;
|
||||||
|
reset: () => void;
|
||||||
|
focus: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createRuntimeWithTerminal(): {
|
||||||
|
runtime: TerminalEmulatorRuntime;
|
||||||
|
terminal: StubTerminal & {
|
||||||
|
resetCalls: number;
|
||||||
|
};
|
||||||
|
writeCallbacks: Array<() => void>;
|
||||||
|
writeTexts: string[];
|
||||||
|
} {
|
||||||
|
const runtime = new TerminalEmulatorRuntime();
|
||||||
|
const writeCallbacks: Array<() => void> = [];
|
||||||
|
const writeTexts: string[] = [];
|
||||||
|
let resetCalls = 0;
|
||||||
|
|
||||||
|
const terminal: StubTerminal & { resetCalls: number } = {
|
||||||
|
write: (text: string, callback?: () => void) => {
|
||||||
|
writeTexts.push(text);
|
||||||
|
if (callback) {
|
||||||
|
writeCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reset: () => {
|
||||||
|
resetCalls += 1;
|
||||||
|
terminal.resetCalls = resetCalls;
|
||||||
|
},
|
||||||
|
focus: () => {},
|
||||||
|
resetCalls,
|
||||||
|
};
|
||||||
|
|
||||||
|
(runtime as unknown as { terminal: StubTerminal }).terminal = terminal;
|
||||||
|
|
||||||
|
return {
|
||||||
|
runtime,
|
||||||
|
terminal,
|
||||||
|
writeCallbacks,
|
||||||
|
writeTexts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("terminal-emulator-runtime", () => {
|
||||||
|
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis as { window?: { __paseoTerminal?: unknown } }).window = {
|
||||||
|
__paseoTerminal: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
(globalThis as { window?: unknown }).window = originalWindow;
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("processes write and clear operations in strict order", () => {
|
||||||
|
const { runtime, terminal, writeCallbacks, writeTexts } = createRuntimeWithTerminal();
|
||||||
|
const committed: string[] = [];
|
||||||
|
|
||||||
|
runtime.write({
|
||||||
|
text: "first",
|
||||||
|
onCommitted: () => {
|
||||||
|
committed.push("first");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
runtime.clear({
|
||||||
|
onCommitted: () => {
|
||||||
|
committed.push("clear");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
runtime.write({
|
||||||
|
text: "second",
|
||||||
|
onCommitted: () => {
|
||||||
|
committed.push("second");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(writeTexts).toEqual(["first"]);
|
||||||
|
expect(terminal.resetCalls).toBe(0);
|
||||||
|
expect(committed).toEqual([]);
|
||||||
|
|
||||||
|
writeCallbacks[0]?.();
|
||||||
|
|
||||||
|
expect(committed).toEqual(["first", "clear"]);
|
||||||
|
expect(terminal.resetCalls).toBe(1);
|
||||||
|
expect(writeTexts).toEqual(["first", "second"]);
|
||||||
|
|
||||||
|
writeCallbacks[1]?.();
|
||||||
|
expect(committed).toEqual(["first", "clear", "second"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to timeout commit when xterm write callback does not fire", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { runtime } = createRuntimeWithTerminal();
|
||||||
|
const onCommitted = vi.fn();
|
||||||
|
|
||||||
|
runtime.write({
|
||||||
|
text: "stuck",
|
||||||
|
onCommitted,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onCommitted).not.toHaveBeenCalled();
|
||||||
|
vi.advanceTimersByTime(5_000);
|
||||||
|
expect(onCommitted).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores stale duplicate write callbacks from a previous operation", () => {
|
||||||
|
const { runtime, writeCallbacks } = createRuntimeWithTerminal();
|
||||||
|
const committed: string[] = [];
|
||||||
|
|
||||||
|
runtime.write({
|
||||||
|
text: "first",
|
||||||
|
onCommitted: () => {
|
||||||
|
committed.push("first");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
runtime.write({
|
||||||
|
text: "second",
|
||||||
|
onCommitted: () => {
|
||||||
|
committed.push("second");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
writeCallbacks[0]?.();
|
||||||
|
expect(committed).toEqual(["first"]);
|
||||||
|
|
||||||
|
writeCallbacks[0]?.();
|
||||||
|
expect(committed).toEqual(["first"]);
|
||||||
|
|
||||||
|
writeCallbacks[1]?.();
|
||||||
|
expect(committed).toEqual(["first", "second"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits pending output operations during unmount to avoid deadlock", () => {
|
||||||
|
const { runtime } = createRuntimeWithTerminal();
|
||||||
|
const onCommittedA = vi.fn();
|
||||||
|
const onCommittedB = vi.fn();
|
||||||
|
|
||||||
|
runtime.write({
|
||||||
|
text: "a",
|
||||||
|
onCommitted: onCommittedA,
|
||||||
|
});
|
||||||
|
runtime.write({
|
||||||
|
text: "b",
|
||||||
|
onCommitted: onCommittedB,
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.unmount();
|
||||||
|
|
||||||
|
expect(onCommittedA).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onCommittedB).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,6 +51,12 @@ type TerminalEmulatorRuntimeDisposables = {
|
|||||||
disposeTerminal: () => void;
|
disposeTerminal: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TerminalOutputOperation = {
|
||||||
|
type: "write" | "clear";
|
||||||
|
text: string;
|
||||||
|
onCommitted?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__paseoTerminal?: Terminal;
|
__paseoTerminal?: Terminal;
|
||||||
@@ -59,6 +65,7 @@ declare global {
|
|||||||
|
|
||||||
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
|
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
|
||||||
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
|
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
|
||||||
|
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
export class TerminalEmulatorRuntime {
|
export class TerminalEmulatorRuntime {
|
||||||
private callbacks: TerminalEmulatorRuntimeCallbacks = {};
|
private callbacks: TerminalEmulatorRuntimeCallbacks = {};
|
||||||
@@ -71,8 +78,9 @@ export class TerminalEmulatorRuntime {
|
|||||||
private fitAddon: FitAddon | null = null;
|
private fitAddon: FitAddon | null = null;
|
||||||
private lastSize: { rows: number; cols: number } | null = null;
|
private lastSize: { rows: number; cols: number } | null = null;
|
||||||
private cleanup: (() => void) | null = null;
|
private cleanup: (() => void) | null = null;
|
||||||
private pendingWriteText = "";
|
private outputOperations: TerminalOutputOperation[] = [];
|
||||||
private isWriteFlushQueued = false;
|
private inFlightOutputOperation: TerminalOutputOperation | null = null;
|
||||||
|
private inFlightOutputOperationTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
setCallbacks(input: { callbacks: TerminalEmulatorRuntimeCallbacks }): void {
|
setCallbacks(input: { callbacks: TerminalEmulatorRuntimeCallbacks }): void {
|
||||||
this.callbacks = input.callbacks;
|
this.callbacks = input.callbacks;
|
||||||
@@ -287,6 +295,8 @@ export class TerminalEmulatorRuntime {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.processOutputQueue();
|
||||||
|
|
||||||
const disposables: TerminalEmulatorRuntimeDisposables = {
|
const disposables: TerminalEmulatorRuntimeDisposables = {
|
||||||
disposeInput: () => {
|
disposeInput: () => {
|
||||||
inputDisposable.dispose();
|
inputDisposable.dispose();
|
||||||
@@ -338,30 +348,39 @@ export class TerminalEmulatorRuntime {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
write(input: { text: string }): void {
|
write(input: { text: string; onCommitted?: () => void }): void {
|
||||||
if (!this.terminal || input.text.length === 0) {
|
if (input.text.length === 0) {
|
||||||
|
input.onCommitted?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.pendingWriteText += input.text;
|
this.outputOperations.push({
|
||||||
|
type: "write",
|
||||||
|
text: input.text,
|
||||||
|
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
|
||||||
|
});
|
||||||
terminalDebugLog({
|
terminalDebugLog({
|
||||||
scope: "emulator-runtime",
|
scope: "emulator-runtime",
|
||||||
event: "output:enqueue",
|
event: "output:enqueue",
|
||||||
details: {
|
details: {
|
||||||
chunkLength: input.text.length,
|
chunkLength: input.text.length,
|
||||||
queueLength: this.pendingWriteText.length,
|
queueLength: this.outputOperations.length,
|
||||||
preview: summarizeTerminalText({ text: input.text, maxChars: 64 }),
|
preview: summarizeTerminalText({ text: input.text, maxChars: 64 }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.scheduleWriteFlush();
|
this.processOutputQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(input?: { onCommitted?: () => void }): void {
|
||||||
this.pendingWriteText = "";
|
this.outputOperations.push({
|
||||||
|
type: "clear",
|
||||||
|
text: "",
|
||||||
|
...(input?.onCommitted ? { onCommitted: input.onCommitted } : {}),
|
||||||
|
});
|
||||||
terminalDebugLog({
|
terminalDebugLog({
|
||||||
scope: "emulator-runtime",
|
scope: "emulator-runtime",
|
||||||
event: "output:clear",
|
event: "output:clear",
|
||||||
});
|
});
|
||||||
this.terminal?.reset();
|
this.processOutputQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
focus(): void {
|
focus(): void {
|
||||||
@@ -373,11 +392,19 @@ export class TerminalEmulatorRuntime {
|
|||||||
scope: "emulator-runtime",
|
scope: "emulator-runtime",
|
||||||
event: "mount:unmount",
|
event: "mount:unmount",
|
||||||
details: {
|
details: {
|
||||||
pendingWriteLength: this.pendingWriteText.length,
|
pendingWriteOperations: this.outputOperations.length,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.pendingWriteText = "";
|
this.clearInFlightOutputTimeout();
|
||||||
this.isWriteFlushQueued = false;
|
const inFlightOperation = this.inFlightOutputOperation;
|
||||||
|
this.inFlightOutputOperation = null;
|
||||||
|
if (inFlightOperation?.onCommitted) {
|
||||||
|
inFlightOperation.onCommitted();
|
||||||
|
}
|
||||||
|
const pendingOperations = this.outputOperations.splice(0, this.outputOperations.length);
|
||||||
|
for (const operation of pendingOperations) {
|
||||||
|
operation.onCommitted?.();
|
||||||
|
}
|
||||||
|
|
||||||
this.cleanup?.();
|
this.cleanup?.();
|
||||||
this.cleanup = null;
|
this.cleanup = null;
|
||||||
@@ -389,24 +416,39 @@ export class TerminalEmulatorRuntime {
|
|||||||
this.lastSize = null;
|
this.lastSize = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleWriteFlush(): void {
|
private processOutputQueue(): void {
|
||||||
if (this.isWriteFlushQueued) {
|
if (this.inFlightOutputOperation) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isWriteFlushQueued = true;
|
const terminal = this.terminal;
|
||||||
queueMicrotask(() => {
|
if (!terminal) {
|
||||||
this.isWriteFlushQueued = false;
|
|
||||||
this.flushWriteQueue();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private flushWriteQueue(): void {
|
|
||||||
if (!this.terminal || this.pendingWriteText.length === 0) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const text = this.pendingWriteText;
|
|
||||||
this.pendingWriteText = "";
|
const operation = this.outputOperations.shift();
|
||||||
|
if (!operation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.inFlightOutputOperation = operation;
|
||||||
|
const finalizeOperation = (expectedOperation: TerminalOutputOperation) => {
|
||||||
|
if (this.inFlightOutputOperation !== expectedOperation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.inFlightOutputOperation = null;
|
||||||
|
this.clearInFlightOutputTimeout();
|
||||||
|
expectedOperation.onCommitted?.();
|
||||||
|
this.processOutputQueue();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (operation.type === "clear") {
|
||||||
|
terminal.reset();
|
||||||
|
finalizeOperation(operation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = operation.text;
|
||||||
terminalDebugLog({
|
terminalDebugLog({
|
||||||
scope: "emulator-runtime",
|
scope: "emulator-runtime",
|
||||||
event: "output:flush",
|
event: "output:flush",
|
||||||
@@ -415,7 +457,33 @@ export class TerminalEmulatorRuntime {
|
|||||||
preview: summarizeTerminalText({ text, maxChars: 96 }),
|
preview: summarizeTerminalText({ text, maxChars: 96 }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.terminal.write(text);
|
this.inFlightOutputOperationTimeout = setTimeout(() => {
|
||||||
|
terminalDebugLog({
|
||||||
|
scope: "emulator-runtime",
|
||||||
|
event: "output:flush-timeout",
|
||||||
|
details: {
|
||||||
|
length: text.length,
|
||||||
|
preview: summarizeTerminalText({ text, maxChars: 96 }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
finalizeOperation(operation);
|
||||||
|
}, OUTPUT_OPERATION_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
terminal.write(text, () => {
|
||||||
|
finalizeOperation(operation);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
finalizeOperation(operation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearInFlightOutputTimeout(): void {
|
||||||
|
if (!this.inFlightOutputOperationTimeout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(this.inFlightOutputOperationTimeout);
|
||||||
|
this.inFlightOutputOperationTimeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyDocumentBoundsStyles(input: { root: HTMLDivElement }): () => void {
|
private applyDocumentBoundsStyles(input: { root: HTMLDivElement }): () => void {
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { TerminalOutputDeliveryQueue } from "./terminal-output-delivery-queue";
|
||||||
|
|
||||||
|
describe("terminal-output-delivery-queue", () => {
|
||||||
|
it("retries in-flight delivery when consume is missing", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const delivered: Array<{ sequence: number; text: string }> = [];
|
||||||
|
const queue = new TerminalOutputDeliveryQueue({
|
||||||
|
onDeliver: (chunk) => {
|
||||||
|
delivered.push(chunk);
|
||||||
|
},
|
||||||
|
deliveryTimeoutMs: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
queue.enqueue({ sequence: 1, text: "a" });
|
||||||
|
queue.enqueue({ sequence: 2, text: "b" });
|
||||||
|
|
||||||
|
expect(delivered).toEqual([{ sequence: 1, text: "a" }]);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
expect(delivered).toEqual([
|
||||||
|
{ sequence: 1, text: "a" },
|
||||||
|
{ sequence: 1, text: "a" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
queue.consume({ sequence: 1 });
|
||||||
|
expect(delivered).toEqual([
|
||||||
|
{ sequence: 1, text: "a" },
|
||||||
|
{ sequence: 1, text: "a" },
|
||||||
|
{ sequence: 2, text: "b" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delivers first chunk immediately and blocks later chunks until consumed", () => {
|
||||||
|
const delivered: Array<{ sequence: number; text: string }> = [];
|
||||||
|
const queue = new TerminalOutputDeliveryQueue({
|
||||||
|
onDeliver: (chunk) => {
|
||||||
|
delivered.push(chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
queue.enqueue({ sequence: 1, text: "a" });
|
||||||
|
queue.enqueue({ sequence: 2, text: "b" });
|
||||||
|
queue.enqueue({ sequence: 3, text: "c" });
|
||||||
|
|
||||||
|
expect(delivered).toEqual([{ sequence: 1, text: "a" }]);
|
||||||
|
|
||||||
|
queue.consume({ sequence: 1 });
|
||||||
|
expect(delivered).toEqual([
|
||||||
|
{ sequence: 1, text: "a" },
|
||||||
|
{ sequence: 3, text: "bc" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores stale consume acknowledgements", () => {
|
||||||
|
const delivered = vi.fn();
|
||||||
|
const queue = new TerminalOutputDeliveryQueue({ onDeliver: delivered });
|
||||||
|
|
||||||
|
queue.enqueue({ sequence: 1, text: "x" });
|
||||||
|
queue.consume({ sequence: 99 });
|
||||||
|
queue.enqueue({ sequence: 2, text: "y" });
|
||||||
|
|
||||||
|
expect(delivered).toHaveBeenCalledTimes(1);
|
||||||
|
expect(delivered).toHaveBeenNthCalledWith(1, { sequence: 1, text: "x" });
|
||||||
|
|
||||||
|
queue.consume({ sequence: 1 });
|
||||||
|
|
||||||
|
expect(delivered).toHaveBeenCalledTimes(2);
|
||||||
|
expect(delivered).toHaveBeenNthCalledWith(2, { sequence: 2, text: "y" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets in-flight and pending chunks", () => {
|
||||||
|
const delivered: Array<{ sequence: number; text: string }> = [];
|
||||||
|
const queue = new TerminalOutputDeliveryQueue({
|
||||||
|
onDeliver: (chunk) => {
|
||||||
|
delivered.push(chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
queue.enqueue({ sequence: 1, text: "hello" });
|
||||||
|
queue.enqueue({ sequence: 2, text: " world" });
|
||||||
|
|
||||||
|
queue.reset();
|
||||||
|
queue.enqueue({ sequence: 3, text: "next" });
|
||||||
|
|
||||||
|
expect(delivered).toEqual([
|
||||||
|
{ sequence: 1, text: "hello" },
|
||||||
|
{ sequence: 3, text: "next" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves empty chunk payloads for authoritative clears", () => {
|
||||||
|
const delivered: Array<{ sequence: number; text: string }> = [];
|
||||||
|
const queue = new TerminalOutputDeliveryQueue({
|
||||||
|
onDeliver: (chunk) => {
|
||||||
|
delivered.push(chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
queue.enqueue({ sequence: 1, text: "abc" });
|
||||||
|
queue.consume({ sequence: 1 });
|
||||||
|
queue.enqueue({ sequence: 2, text: "" });
|
||||||
|
|
||||||
|
expect(delivered).toEqual([
|
||||||
|
{ sequence: 1, text: "abc" },
|
||||||
|
{ sequence: 2, text: "" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats clear chunks as a delivery barrier and drops pending stale text", () => {
|
||||||
|
const delivered: Array<{ sequence: number; text: string }> = [];
|
||||||
|
const queue = new TerminalOutputDeliveryQueue({
|
||||||
|
onDeliver: (chunk) => {
|
||||||
|
delivered.push(chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
queue.enqueue({ sequence: 1, text: "a" });
|
||||||
|
queue.enqueue({ sequence: 2, text: "b" });
|
||||||
|
queue.enqueue({ sequence: 3, text: "" });
|
||||||
|
queue.enqueue({ sequence: 4, text: "c" });
|
||||||
|
|
||||||
|
expect(delivered).toEqual([{ sequence: 1, text: "a" }]);
|
||||||
|
|
||||||
|
queue.consume({ sequence: 1 });
|
||||||
|
expect(delivered).toEqual([
|
||||||
|
{ sequence: 1, text: "a" },
|
||||||
|
{ sequence: 3, text: "" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
queue.consume({ sequence: 3 });
|
||||||
|
expect(delivered).toEqual([
|
||||||
|
{ sequence: 1, text: "a" },
|
||||||
|
{ sequence: 3, text: "" },
|
||||||
|
{ sequence: 4, text: "c" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
|
||||||
|
|
||||||
|
export type TerminalOutputDeliveryChunk = {
|
||||||
|
sequence: number;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalOutputDeliveryQueueOptions = {
|
||||||
|
onDeliver: (chunk: TerminalOutputDeliveryChunk) => void;
|
||||||
|
deliveryTimeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_DELIVERY_TIMEOUT_MS = 8_000;
|
||||||
|
|
||||||
|
export class TerminalOutputDeliveryQueue {
|
||||||
|
private readonly pendingChunks: TerminalOutputDeliveryChunk[] = [];
|
||||||
|
private inFlightChunk: TerminalOutputDeliveryChunk | null = null;
|
||||||
|
private inFlightTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private lastSeenSequence = 0;
|
||||||
|
private readonly deliveryTimeoutMs: number;
|
||||||
|
|
||||||
|
constructor(private readonly options: TerminalOutputDeliveryQueueOptions) {
|
||||||
|
this.deliveryTimeoutMs =
|
||||||
|
options.deliveryTimeoutMs ?? DEFAULT_DELIVERY_TIMEOUT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(chunk: TerminalOutputDeliveryChunk): void {
|
||||||
|
if (chunk.sequence <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (chunk.sequence <= this.lastSeenSequence) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastSeenSequence = chunk.sequence;
|
||||||
|
|
||||||
|
if (chunk.text.length === 0) {
|
||||||
|
this.pendingChunks.length = 0;
|
||||||
|
this.pendingChunks.push(chunk);
|
||||||
|
terminalDebugLog({
|
||||||
|
scope: "output-delivery-queue",
|
||||||
|
event: "enqueue:clear",
|
||||||
|
details: {
|
||||||
|
sequence: chunk.sequence,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.tryDeliver();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastPendingChunk = this.pendingChunks[this.pendingChunks.length - 1];
|
||||||
|
if (lastPendingChunk && lastPendingChunk.text.length > 0) {
|
||||||
|
lastPendingChunk.sequence = chunk.sequence;
|
||||||
|
lastPendingChunk.text += chunk.text;
|
||||||
|
} else {
|
||||||
|
this.pendingChunks.push(chunk);
|
||||||
|
}
|
||||||
|
terminalDebugLog({
|
||||||
|
scope: "output-delivery-queue",
|
||||||
|
event: "enqueue:text",
|
||||||
|
details: {
|
||||||
|
sequence: chunk.sequence,
|
||||||
|
pendingCount: this.pendingChunks.length,
|
||||||
|
textLength: chunk.text.length,
|
||||||
|
preview: summarizeTerminalText({ text: chunk.text, maxChars: 80 }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.tryDeliver();
|
||||||
|
}
|
||||||
|
|
||||||
|
consume(input: { sequence: number }): void {
|
||||||
|
if (this.inFlightChunk?.sequence !== input.sequence) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
terminalDebugLog({
|
||||||
|
scope: "output-delivery-queue",
|
||||||
|
event: "consume",
|
||||||
|
details: {
|
||||||
|
sequence: input.sequence,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.clearInFlightTimeout();
|
||||||
|
this.inFlightChunk = null;
|
||||||
|
this.tryDeliver();
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.clearInFlightTimeout();
|
||||||
|
this.pendingChunks.length = 0;
|
||||||
|
this.inFlightChunk = null;
|
||||||
|
this.lastSeenSequence = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private tryDeliver(): void {
|
||||||
|
if (this.inFlightChunk) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextChunk = this.pendingChunks.shift();
|
||||||
|
if (!nextChunk) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.inFlightChunk = nextChunk;
|
||||||
|
terminalDebugLog({
|
||||||
|
scope: "output-delivery-queue",
|
||||||
|
event: "deliver:start",
|
||||||
|
details: {
|
||||||
|
sequence: nextChunk.sequence,
|
||||||
|
pendingCount: this.pendingChunks.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.deliverInFlightChunk();
|
||||||
|
}
|
||||||
|
|
||||||
|
private deliverInFlightChunk(): void {
|
||||||
|
const chunk = this.inFlightChunk;
|
||||||
|
if (!chunk) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearInFlightTimeout();
|
||||||
|
this.inFlightTimeout = setTimeout(() => {
|
||||||
|
if (!this.inFlightChunk) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
terminalDebugLog({
|
||||||
|
scope: "output-delivery-queue",
|
||||||
|
event: "deliver:timeout-retry",
|
||||||
|
details: {
|
||||||
|
sequence: this.inFlightChunk.sequence,
|
||||||
|
pendingCount: this.pendingChunks.length,
|
||||||
|
timeoutMs: this.deliveryTimeoutMs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.deliverInFlightChunk();
|
||||||
|
}, this.deliveryTimeoutMs);
|
||||||
|
|
||||||
|
this.options.onDeliver({
|
||||||
|
sequence: chunk.sequence,
|
||||||
|
text: chunk.text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearInFlightTimeout(): void {
|
||||||
|
if (!this.inFlightTimeout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(this.inFlightTimeout);
|
||||||
|
this.inFlightTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ type FakeStreamSubscriber = (chunk: TerminalStreamControllerChunk) => void;
|
|||||||
|
|
||||||
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
||||||
private readonly streamSubscribers = new Map<number, Set<FakeStreamSubscriber>>();
|
private readonly streamSubscribers = new Map<number, Set<FakeStreamSubscriber>>();
|
||||||
|
private readonly pendingChunksByStreamId = new Map<number, TerminalStreamControllerChunk[]>();
|
||||||
public attachCalls: Array<{
|
public attachCalls: Array<{
|
||||||
terminalId: string;
|
terminalId: string;
|
||||||
options?: {
|
options?: {
|
||||||
@@ -47,6 +48,14 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
|||||||
streamId: number,
|
streamId: number,
|
||||||
handler: (chunk: TerminalStreamControllerChunk) => void
|
handler: (chunk: TerminalStreamControllerChunk) => void
|
||||||
): () => void {
|
): () => void {
|
||||||
|
const pendingChunks = this.pendingChunksByStreamId.get(streamId);
|
||||||
|
if (pendingChunks && pendingChunks.length > 0) {
|
||||||
|
for (const chunk of pendingChunks) {
|
||||||
|
handler(chunk);
|
||||||
|
}
|
||||||
|
this.pendingChunksByStreamId.delete(streamId);
|
||||||
|
}
|
||||||
|
|
||||||
const subscribers = this.streamSubscribers.get(streamId) ?? new Set();
|
const subscribers = this.streamSubscribers.get(streamId) ?? new Set();
|
||||||
subscribers.add(handler);
|
subscribers.add(handler);
|
||||||
this.streamSubscribers.set(streamId, subscribers);
|
this.streamSubscribers.set(streamId, subscribers);
|
||||||
@@ -61,21 +70,44 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
|||||||
|
|
||||||
emitChunk(input: {
|
emitChunk(input: {
|
||||||
streamId: number;
|
streamId: number;
|
||||||
|
offset?: number;
|
||||||
endOffset: number;
|
endOffset: number;
|
||||||
|
replay?: boolean;
|
||||||
data: string;
|
data: string;
|
||||||
}): void {
|
}): void {
|
||||||
const subscribers = this.streamSubscribers.get(input.streamId);
|
const subscribers = this.streamSubscribers.get(input.streamId);
|
||||||
if (!subscribers || subscribers.size === 0) {
|
if (!subscribers || subscribers.size === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const bytes = new TextEncoder().encode(input.data);
|
||||||
const chunk: TerminalStreamControllerChunk = {
|
const chunk: TerminalStreamControllerChunk = {
|
||||||
|
offset: input.offset ?? input.endOffset - bytes.byteLength,
|
||||||
endOffset: input.endOffset,
|
endOffset: input.endOffset,
|
||||||
data: new TextEncoder().encode(input.data),
|
replay: input.replay,
|
||||||
|
data: bytes,
|
||||||
};
|
};
|
||||||
for (const subscriber of subscribers) {
|
for (const subscriber of subscribers) {
|
||||||
subscriber(chunk);
|
subscriber(chunk);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bufferChunk(input: {
|
||||||
|
streamId: number;
|
||||||
|
offset?: number;
|
||||||
|
endOffset: number;
|
||||||
|
replay?: boolean;
|
||||||
|
data: string;
|
||||||
|
}): void {
|
||||||
|
const chunks = this.pendingChunksByStreamId.get(input.streamId) ?? [];
|
||||||
|
const bytes = new TextEncoder().encode(input.data);
|
||||||
|
chunks.push({
|
||||||
|
offset: input.offset ?? input.endOffset - bytes.byteLength,
|
||||||
|
endOffset: input.endOffset,
|
||||||
|
replay: input.replay,
|
||||||
|
data: bytes,
|
||||||
|
});
|
||||||
|
this.pendingChunksByStreamId.set(input.streamId, chunks);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createControllerHarness(input?: {
|
function createControllerHarness(input?: {
|
||||||
@@ -251,4 +283,203 @@ describe("terminal-stream-controller", () => {
|
|||||||
expect(harness.resets).toEqual(["term-reset"]);
|
expect(harness.resets).toEqual(["term-reset"]);
|
||||||
expect(harness.controller.getActiveStreamId()).toBe(12);
|
expect(harness.controller.getActiveStreamId()).toBe(12);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("delivers buffered replay chunks flushed synchronously during subscribe", async () => {
|
||||||
|
const harness = createControllerHarness();
|
||||||
|
harness.client.nextAttachResponses.push({
|
||||||
|
streamId: 17,
|
||||||
|
replayedFrom: 0,
|
||||||
|
currentOffset: 10,
|
||||||
|
reset: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
harness.client.bufferChunk({
|
||||||
|
streamId: 17,
|
||||||
|
offset: 0,
|
||||||
|
endOffset: 10,
|
||||||
|
replay: true,
|
||||||
|
data: "buffered-replay",
|
||||||
|
});
|
||||||
|
|
||||||
|
harness.controller.setTerminal({ terminalId: "term-buffered" });
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(harness.chunks).toEqual([
|
||||||
|
{ terminalId: "term-buffered", text: "buffered-replay" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears stale selected output when bootstrap replay starts before current offset", async () => {
|
||||||
|
const harness = createControllerHarness();
|
||||||
|
harness.client.nextAttachResponses.push({
|
||||||
|
streamId: 61,
|
||||||
|
replayedFrom: 0,
|
||||||
|
currentOffset: 20,
|
||||||
|
reset: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
harness.controller.setTerminal({ terminalId: "term-bootstrap-reset" });
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(harness.resets).toEqual(["term-bootstrap-reset"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reattaches and replays from last contiguous offset when chunk offsets gap", async () => {
|
||||||
|
const harness = createControllerHarness();
|
||||||
|
harness.client.nextAttachResponses.push({
|
||||||
|
streamId: 31,
|
||||||
|
currentOffset: 0,
|
||||||
|
reset: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
harness.client.nextAttachResponses.push({
|
||||||
|
streamId: 32,
|
||||||
|
currentOffset: 8,
|
||||||
|
reset: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
harness.controller.setTerminal({ terminalId: "term-gap" });
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
harness.client.emitChunk({
|
||||||
|
streamId: 31,
|
||||||
|
offset: 0,
|
||||||
|
endOffset: 2,
|
||||||
|
data: "ok",
|
||||||
|
});
|
||||||
|
harness.client.emitChunk({
|
||||||
|
streamId: 31,
|
||||||
|
offset: 4,
|
||||||
|
endOffset: 8,
|
||||||
|
data: "miss",
|
||||||
|
});
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(harness.chunks).toEqual([{ terminalId: "term-gap", text: "ok" }]);
|
||||||
|
expect(harness.client.detachCalls).toContain(31);
|
||||||
|
expect(harness.client.attachCalls.length).toBe(2);
|
||||||
|
expect(harness.client.attachCalls[1]?.options).toEqual({
|
||||||
|
resumeOffset: 2,
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
});
|
||||||
|
expect(harness.controller.getActiveStreamId()).toBe(32);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat replay range before currentOffset as a live gap", async () => {
|
||||||
|
const harness = createControllerHarness();
|
||||||
|
harness.client.nextAttachResponses.push({
|
||||||
|
streamId: 41,
|
||||||
|
replayedFrom: 50,
|
||||||
|
currentOffset: 100,
|
||||||
|
reset: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
harness.client.bufferChunk({
|
||||||
|
streamId: 41,
|
||||||
|
offset: 80,
|
||||||
|
endOffset: 100,
|
||||||
|
data: "replay-tail",
|
||||||
|
});
|
||||||
|
|
||||||
|
harness.controller.setTerminal({ terminalId: "term-replay" });
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
harness.client.emitChunk({
|
||||||
|
streamId: 41,
|
||||||
|
offset: 100,
|
||||||
|
endOffset: 102,
|
||||||
|
data: "ok",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(harness.client.detachCalls).toEqual([]);
|
||||||
|
expect(harness.client.attachCalls).toHaveLength(1);
|
||||||
|
expect(harness.controller.getActiveStreamId()).toBe(41);
|
||||||
|
expect(harness.chunks).toEqual([
|
||||||
|
{ terminalId: "term-replay", text: "replay-tail" },
|
||||||
|
{ terminalId: "term-replay", text: "ok" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts clamped replay start after reconnect without entering a reconnect loop", async () => {
|
||||||
|
const harness = createControllerHarness();
|
||||||
|
harness.client.nextAttachResponses.push({
|
||||||
|
streamId: 71,
|
||||||
|
replayedFrom: 205,
|
||||||
|
currentOffset: 205,
|
||||||
|
reset: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
harness.client.nextAttachResponses.push({
|
||||||
|
streamId: 72,
|
||||||
|
replayedFrom: 396,
|
||||||
|
currentOffset: 978,
|
||||||
|
reset: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
harness.controller.setTerminal({ terminalId: "term-clamped-replay" });
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
harness.client.emitChunk({
|
||||||
|
streamId: 71,
|
||||||
|
offset: 205,
|
||||||
|
endOffset: 299,
|
||||||
|
data: "first",
|
||||||
|
});
|
||||||
|
harness.client.emitChunk({
|
||||||
|
streamId: 71,
|
||||||
|
offset: 396,
|
||||||
|
endOffset: 493,
|
||||||
|
data: "gap",
|
||||||
|
});
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(harness.client.attachCalls).toHaveLength(2);
|
||||||
|
expect(harness.client.attachCalls[1]?.options).toEqual({
|
||||||
|
resumeOffset: 299,
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
});
|
||||||
|
expect(harness.controller.getActiveStreamId()).toBe(72);
|
||||||
|
|
||||||
|
harness.client.emitChunk({
|
||||||
|
streamId: 72,
|
||||||
|
replay: true,
|
||||||
|
offset: 396,
|
||||||
|
endOffset: 493,
|
||||||
|
data: "replay-1",
|
||||||
|
});
|
||||||
|
harness.client.emitChunk({
|
||||||
|
streamId: 72,
|
||||||
|
replay: true,
|
||||||
|
offset: 493,
|
||||||
|
endOffset: 590,
|
||||||
|
data: "replay-2",
|
||||||
|
});
|
||||||
|
harness.client.emitChunk({
|
||||||
|
streamId: 72,
|
||||||
|
replay: true,
|
||||||
|
offset: 687,
|
||||||
|
endOffset: 784,
|
||||||
|
data: "replay-3",
|
||||||
|
});
|
||||||
|
harness.client.emitChunk({
|
||||||
|
streamId: 72,
|
||||||
|
replay: false,
|
||||||
|
offset: 978,
|
||||||
|
endOffset: 1075,
|
||||||
|
data: "live",
|
||||||
|
});
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(harness.client.attachCalls).toHaveLength(2);
|
||||||
|
expect(harness.client.detachCalls.filter((streamId) => streamId === 71)).toHaveLength(1);
|
||||||
|
expect(harness.chunks.at(-1)).toEqual({
|
||||||
|
terminalId: "term-clamped-replay",
|
||||||
|
text: "live",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,13 +10,16 @@ import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
|
|||||||
|
|
||||||
export type TerminalStreamControllerAttachPayload = {
|
export type TerminalStreamControllerAttachPayload = {
|
||||||
streamId: number | null;
|
streamId: number | null;
|
||||||
|
replayedFrom?: number;
|
||||||
currentOffset: number;
|
currentOffset: number;
|
||||||
reset: boolean;
|
reset: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TerminalStreamControllerChunk = {
|
export type TerminalStreamControllerChunk = {
|
||||||
|
offset: number;
|
||||||
endOffset: number;
|
endOffset: number;
|
||||||
|
replay?: boolean;
|
||||||
data: Uint8Array;
|
data: Uint8Array;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,6 +74,8 @@ type TerminalStreamControllerActiveStream = {
|
|||||||
terminalId: string;
|
terminalId: string;
|
||||||
streamId: number;
|
streamId: number;
|
||||||
decoder: TextDecoder;
|
decoder: TextDecoder;
|
||||||
|
nextExpectedOffset: number | null;
|
||||||
|
catchUpEndOffset: number | null;
|
||||||
unsubscribe: () => void;
|
unsubscribe: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -308,6 +313,37 @@ export class TerminalStreamController {
|
|||||||
|
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
const streamId = attachPayload.streamId;
|
const streamId = attachPayload.streamId;
|
||||||
|
const replayedFromOffset =
|
||||||
|
typeof attachPayload.replayedFrom === "number"
|
||||||
|
? Math.max(0, Math.floor(attachPayload.replayedFrom))
|
||||||
|
: null;
|
||||||
|
const currentOffset = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor(attachPayload.currentOffset)
|
||||||
|
);
|
||||||
|
const shouldResetForReplayBootstrap =
|
||||||
|
typeof resumeOffset !== "number" &&
|
||||||
|
typeof replayedFromOffset === "number" &&
|
||||||
|
replayedFromOffset < currentOffset;
|
||||||
|
if (shouldResetForReplayBootstrap) {
|
||||||
|
this.options.onReset?.({ terminalId: input.terminalId });
|
||||||
|
}
|
||||||
|
const startExpectedOffset =
|
||||||
|
replayedFromOffset ??
|
||||||
|
(typeof resumeOffset === "number"
|
||||||
|
? Math.max(0, Math.floor(resumeOffset))
|
||||||
|
: currentOffset);
|
||||||
|
const catchUpEndOffset = currentOffset;
|
||||||
|
|
||||||
|
const activeStream: TerminalStreamControllerActiveStream = {
|
||||||
|
terminalId: input.terminalId,
|
||||||
|
streamId,
|
||||||
|
decoder,
|
||||||
|
nextExpectedOffset: startExpectedOffset,
|
||||||
|
catchUpEndOffset,
|
||||||
|
unsubscribe: () => {},
|
||||||
|
};
|
||||||
|
this.activeStream = activeStream;
|
||||||
const unsubscribe = this.options.client.onTerminalStreamData(streamId, (chunk) => {
|
const unsubscribe = this.options.client.onTerminalStreamData(streamId, (chunk) => {
|
||||||
this.handleChunk({
|
this.handleChunk({
|
||||||
terminalId: input.terminalId,
|
terminalId: input.terminalId,
|
||||||
@@ -316,21 +352,22 @@ export class TerminalStreamController {
|
|||||||
decoder,
|
decoder,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
if (this.activeStream === activeStream) {
|
||||||
this.activeStream = {
|
activeStream.unsubscribe = unsubscribe;
|
||||||
terminalId: input.terminalId,
|
} else {
|
||||||
streamId,
|
unsubscribe();
|
||||||
decoder,
|
}
|
||||||
unsubscribe,
|
|
||||||
};
|
|
||||||
terminalDebugLog({
|
terminalDebugLog({
|
||||||
scope: "stream-controller",
|
scope: "stream-controller",
|
||||||
event: "attach:success",
|
event: "attach:success",
|
||||||
details: {
|
details: {
|
||||||
terminalId: input.terminalId,
|
terminalId: input.terminalId,
|
||||||
streamId,
|
streamId,
|
||||||
|
replayedFrom: attachPayload.replayedFrom ?? null,
|
||||||
|
requestedResumeOffset: resumeOffset ?? null,
|
||||||
currentOffset: attachPayload.currentOffset,
|
currentOffset: attachPayload.currentOffset,
|
||||||
reset: attachPayload.reset,
|
reset: attachPayload.reset,
|
||||||
|
bootstrapReset: shouldResetForReplayBootstrap,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.updateStatus({
|
this.updateStatus({
|
||||||
@@ -390,9 +427,66 @@ export class TerminalStreamController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chunkOffset = Number.isFinite(input.chunk.offset)
|
||||||
|
? Math.max(0, Math.floor(input.chunk.offset))
|
||||||
|
: 0;
|
||||||
|
const chunkEndOffset = Number.isFinite(input.chunk.endOffset)
|
||||||
|
? Math.max(0, Math.floor(input.chunk.endOffset))
|
||||||
|
: chunkOffset;
|
||||||
|
if (chunkEndOffset < chunkOffset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedOffset = activeStream.nextExpectedOffset;
|
||||||
|
if (typeof expectedOffset === "number") {
|
||||||
|
if (chunkEndOffset <= expectedOffset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (chunkOffset !== expectedOffset) {
|
||||||
|
const catchUpEndOffset = activeStream.catchUpEndOffset;
|
||||||
|
const canSkipReplayGap =
|
||||||
|
chunkOffset > expectedOffset &&
|
||||||
|
typeof catchUpEndOffset === "number" &&
|
||||||
|
expectedOffset < catchUpEndOffset &&
|
||||||
|
chunkOffset <= catchUpEndOffset;
|
||||||
|
|
||||||
|
if (canSkipReplayGap) {
|
||||||
|
terminalDebugLog({
|
||||||
|
scope: "stream-controller",
|
||||||
|
event: "stream:replay-gap-skip",
|
||||||
|
details: {
|
||||||
|
terminalId: input.terminalId,
|
||||||
|
streamId: input.streamId,
|
||||||
|
expectedOffset,
|
||||||
|
observedOffset: chunkOffset,
|
||||||
|
catchUpEndOffset,
|
||||||
|
replay: Boolean(input.chunk.replay),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
activeStream.nextExpectedOffset = chunkOffset;
|
||||||
|
} else {
|
||||||
|
this.recoverFromStreamGap({
|
||||||
|
terminalId: input.terminalId,
|
||||||
|
streamId: input.streamId,
|
||||||
|
expectedOffset,
|
||||||
|
observedOffset: chunkOffset,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof activeStream.catchUpEndOffset === "number" &&
|
||||||
|
chunkEndOffset >= activeStream.catchUpEndOffset
|
||||||
|
) {
|
||||||
|
activeStream.catchUpEndOffset = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeStream.nextExpectedOffset = chunkEndOffset;
|
||||||
updateTerminalResumeOffset({
|
updateTerminalResumeOffset({
|
||||||
terminalId: input.terminalId,
|
terminalId: input.terminalId,
|
||||||
offset: input.chunk.endOffset,
|
offset: chunkEndOffset,
|
||||||
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
|
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -406,7 +500,9 @@ export class TerminalStreamController {
|
|||||||
details: {
|
details: {
|
||||||
terminalId: input.terminalId,
|
terminalId: input.terminalId,
|
||||||
streamId: input.streamId,
|
streamId: input.streamId,
|
||||||
endOffset: input.chunk.endOffset,
|
offset: chunkOffset,
|
||||||
|
endOffset: chunkEndOffset,
|
||||||
|
replay: Boolean(input.chunk.replay),
|
||||||
byteLength: input.chunk.data.byteLength,
|
byteLength: input.chunk.data.byteLength,
|
||||||
textLength: text.length,
|
textLength: text.length,
|
||||||
preview: summarizeTerminalText({ text, maxChars: 96 }),
|
preview: summarizeTerminalText({ text, maxChars: 96 }),
|
||||||
@@ -419,6 +515,57 @@ export class TerminalStreamController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private recoverFromStreamGap(input: {
|
||||||
|
terminalId: string;
|
||||||
|
streamId: number;
|
||||||
|
expectedOffset: number;
|
||||||
|
observedOffset: number;
|
||||||
|
}): void {
|
||||||
|
const activeStream = this.activeStream;
|
||||||
|
if (!activeStream) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activeStream.streamId !== input.streamId || activeStream.terminalId !== input.terminalId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.selectedTerminalId !== input.terminalId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
terminalDebugLog({
|
||||||
|
scope: "stream-controller",
|
||||||
|
event: "stream:gap-reconnect",
|
||||||
|
details: {
|
||||||
|
terminalId: input.terminalId,
|
||||||
|
streamId: input.streamId,
|
||||||
|
expectedOffset: input.expectedOffset,
|
||||||
|
observedOffset: input.observedOffset,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
updateTerminalResumeOffset({
|
||||||
|
terminalId: input.terminalId,
|
||||||
|
offset: input.expectedOffset,
|
||||||
|
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.attachGeneration += 1;
|
||||||
|
const generation = this.attachGeneration;
|
||||||
|
|
||||||
|
void this.detachActiveStream({ shouldDetach: true });
|
||||||
|
this.updateStatus({
|
||||||
|
terminalId: input.terminalId,
|
||||||
|
streamId: null,
|
||||||
|
isAttaching: true,
|
||||||
|
error:
|
||||||
|
this.options.reconnectErrorMessage ?? DEFAULT_RECONNECT_ERROR_MESSAGE,
|
||||||
|
});
|
||||||
|
void this.attachTerminal({
|
||||||
|
terminalId: input.terminalId,
|
||||||
|
generation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async detachActiveStream(input: { shouldDetach: boolean }): Promise<void> {
|
private async detachActiveStream(input: { shouldDetach: boolean }): Promise<void> {
|
||||||
const activeStream = this.activeStream;
|
const activeStream = this.activeStream;
|
||||||
if (!activeStream) {
|
if (!activeStream) {
|
||||||
|
|||||||
@@ -40,7 +40,18 @@ describe("TerminalStreamManager", () => {
|
|||||||
|
|
||||||
expect(seen).toEqual(["hello"]);
|
expect(seen).toEqual(["hello"]);
|
||||||
expect(sendAck).toHaveBeenCalledTimes(1);
|
expect(sendAck).toHaveBeenCalledTimes(1);
|
||||||
expect(sendAck).toHaveBeenCalledWith({ streamId: 7, offset: 9 });
|
});
|
||||||
|
|
||||||
|
test("acks buffered chunks even while no subscriber is attached", () => {
|
||||||
|
const sendAck = vi.fn();
|
||||||
|
const manager = new TerminalStreamManager({ sendAck });
|
||||||
|
|
||||||
|
manager.receiveChunk({
|
||||||
|
chunk: createChunk({ streamId: 2, offset: 0, data: "abc" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendAck).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sendAck).toHaveBeenCalledWith({ streamId: 2, offset: 3 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not ack when every handler throws", () => {
|
test("does not ack when every handler throws", () => {
|
||||||
|
|||||||
@@ -79,8 +79,6 @@ export class TerminalStreamManager {
|
|||||||
const streamHandlers = this.handlers.get(chunk.streamId);
|
const streamHandlers = this.handlers.get(chunk.streamId);
|
||||||
if (!streamHandlers || streamHandlers.size === 0) {
|
if (!streamHandlers || streamHandlers.size === 0) {
|
||||||
this.bufferChunk({ chunk });
|
this.bufferChunk({ chunk });
|
||||||
// Ack buffered chunks immediately to keep the daemon-side stream window
|
|
||||||
// moving even before a UI subscriber is attached.
|
|
||||||
this.maybeAckChunk({
|
this.maybeAckChunk({
|
||||||
streamId: chunk.streamId,
|
streamId: chunk.streamId,
|
||||||
endOffset: chunk.endOffset,
|
endOffset: chunk.endOffset,
|
||||||
|
|||||||
@@ -753,6 +753,48 @@ describe("DaemonClient", () => {
|
|||||||
unsubscribe();
|
unsubscribe();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("handles terminal binary frames delivered as UTF-8 strings", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const seen: string[] = [];
|
||||||
|
client.onTerminalStreamData(11, (chunk) => {
|
||||||
|
seen.push(new TextDecoder().decode(chunk.data));
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = new TextEncoder().encode("ls\r\n");
|
||||||
|
const frame = encodeBinaryMuxFrame({
|
||||||
|
channel: BinaryMuxChannel.Terminal,
|
||||||
|
messageType: TerminalBinaryMessageType.OutputUtf8,
|
||||||
|
streamId: 11,
|
||||||
|
offset: 0,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
const frameAsString = new TextDecoder("utf-8", { fatal: true }).decode(frame);
|
||||||
|
|
||||||
|
mock.triggerMessage(frameAsString);
|
||||||
|
|
||||||
|
expect(seen).toEqual(["ls\r\n"]);
|
||||||
|
expect(mock.sent).toHaveLength(1);
|
||||||
|
const ackFrame = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!);
|
||||||
|
expect(ackFrame?.channel).toBe(BinaryMuxChannel.Terminal);
|
||||||
|
expect(ackFrame?.messageType).toBe(TerminalBinaryMessageType.Ack);
|
||||||
|
expect(ackFrame?.streamId).toBe(11);
|
||||||
|
expect(ackFrame?.offset).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
test("acks buffered terminal chunks when handler is attached", async () => {
|
test("acks buffered terminal chunks when handler is attached", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|||||||
@@ -374,7 +374,9 @@ export async function createAgentManagementMcpServer(
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
agentManager.recordUserMessage(snapshot.id, trimmedPrompt);
|
agentManager.recordUserMessage(snapshot.id, trimmedPrompt, {
|
||||||
|
emitState: false,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
childLogger.error(
|
childLogger.error(
|
||||||
{ err: error, agentId: snapshot.id },
|
{ err: error, agentId: snapshot.id },
|
||||||
@@ -599,7 +601,9 @@ export async function createAgentManagementMcpServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
agentManager.recordUserMessage(agentId, prompt);
|
agentManager.recordUserMessage(agentId, prompt, {
|
||||||
|
emitState: false,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
childLogger.error(
|
childLogger.error(
|
||||||
{ err: error, agentId },
|
{ err: error, agentId },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { mkdtempSync } from "node:fs";
|
import { mkdtempSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
@@ -588,6 +588,74 @@ describe("AgentManager", () => {
|
|||||||
expect(refreshed?.runtimeInfo?.model).toBe("gpt-5.2-codex");
|
expect(refreshed?.runtimeInfo?.model).toBe("gpt-5.2-codex");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps updatedAt monotonic when user message and run start happen in the same millisecond", async () => {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
|
const storagePath = join(workdir, "agents");
|
||||||
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new TestAgentClient(),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
idFactory: () => "00000000-0000-4000-8000-000000000120",
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await manager.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: workdir,
|
||||||
|
});
|
||||||
|
|
||||||
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_750_000_000_000);
|
||||||
|
try {
|
||||||
|
manager.recordUserMessage(snapshot.id, "hello");
|
||||||
|
const afterMessage = manager.getAgent(snapshot.id);
|
||||||
|
expect(afterMessage).toBeDefined();
|
||||||
|
const messageUpdatedAt = afterMessage!.updatedAt.getTime();
|
||||||
|
|
||||||
|
const stream = manager.streamAgent(snapshot.id, "hello");
|
||||||
|
const afterRunStart = manager.getAgent(snapshot.id);
|
||||||
|
expect(afterRunStart).toBeDefined();
|
||||||
|
expect(afterRunStart!.updatedAt.getTime()).toBeGreaterThan(messageUpdatedAt);
|
||||||
|
|
||||||
|
await stream.return(undefined);
|
||||||
|
} finally {
|
||||||
|
nowSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recordUserMessage can skip emitting agent_state when run start will emit running", async () => {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
|
const storagePath = join(workdir, "agents");
|
||||||
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new TestAgentClient(),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
idFactory: () => "00000000-0000-4000-8000-000000000121",
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await manager.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: workdir,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lifecycleUpdates: string[] = [];
|
||||||
|
manager.subscribe((event) => {
|
||||||
|
if (event.type !== "agent_state" || event.agent.id !== snapshot.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lifecycleUpdates.push(event.agent.lifecycle);
|
||||||
|
});
|
||||||
|
lifecycleUpdates.length = 0;
|
||||||
|
|
||||||
|
manager.recordUserMessage(snapshot.id, "hello", { emitState: false });
|
||||||
|
|
||||||
|
expect(lifecycleUpdates).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test("runAgent assembles finalText from trailing assistant chunks", async () => {
|
test("runAgent assembles finalText from trailing assistant chunks", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
|
|||||||
@@ -307,6 +307,15 @@ export class AgentManager {
|
|||||||
this.onAgentAttention = callback;
|
this.onAgentAttention = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private touchUpdatedAt(agent: ManagedAgent): Date {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const previousMs = agent.updatedAt.getTime();
|
||||||
|
const nextMs = nowMs > previousMs ? nowMs : previousMs + 1;
|
||||||
|
const next = new Date(nextMs);
|
||||||
|
agent.updatedAt = next;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
subscribe(callback: AgentSubscriber, options?: SubscribeOptions): () => void {
|
subscribe(callback: AgentSubscriber, options?: SubscribeOptions): () => void {
|
||||||
const targetAgentId =
|
const targetAgentId =
|
||||||
options?.agentId == null
|
options?.agentId == null
|
||||||
@@ -847,7 +856,7 @@ export class AgentManager {
|
|||||||
recordUserMessage(
|
recordUserMessage(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
text: string,
|
text: string,
|
||||||
options?: { messageId?: string }
|
options?: { messageId?: string; emitState?: boolean }
|
||||||
): void {
|
): void {
|
||||||
const agent = this.requireAgent(agentId);
|
const agent = this.requireAgent(agentId);
|
||||||
const item: AgentTimelineItem = {
|
const item: AgentTimelineItem = {
|
||||||
@@ -855,8 +864,8 @@ export class AgentManager {
|
|||||||
text,
|
text,
|
||||||
messageId: options?.messageId,
|
messageId: options?.messageId,
|
||||||
};
|
};
|
||||||
agent.updatedAt = new Date();
|
const updatedAt = this.touchUpdatedAt(agent);
|
||||||
agent.lastUserMessageAt = agent.updatedAt;
|
agent.lastUserMessageAt = updatedAt;
|
||||||
const row = this.recordTimeline(agent, item);
|
const row = this.recordTimeline(agent, item);
|
||||||
this.dispatchStream(agentId, {
|
this.dispatchStream(agentId, {
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
@@ -866,12 +875,14 @@ export class AgentManager {
|
|||||||
seq: row.seq,
|
seq: row.seq,
|
||||||
epoch: this.ensureTimelineState(agent).epoch,
|
epoch: this.ensureTimelineState(agent).epoch,
|
||||||
});
|
});
|
||||||
this.emitState(agent);
|
if (options?.emitState !== false) {
|
||||||
|
this.emitState(agent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
||||||
const agent = this.requireAgent(agentId);
|
const agent = this.requireAgent(agentId);
|
||||||
agent.updatedAt = new Date();
|
this.touchUpdatedAt(agent);
|
||||||
const row = this.recordTimeline(agent, item);
|
const row = this.recordTimeline(agent, item);
|
||||||
this.dispatchStream(agentId, {
|
this.dispatchStream(agentId, {
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
@@ -955,7 +966,7 @@ export class AgentManager {
|
|||||||
agent.lifecycle = "running";
|
agent.lifecycle = "running";
|
||||||
// Bump updatedAt when lifecycle changes so downstream consumers can
|
// Bump updatedAt when lifecycle changes so downstream consumers can
|
||||||
// deterministically order idle->running transitions.
|
// deterministically order idle->running transitions.
|
||||||
agent.updatedAt = new Date();
|
this.touchUpdatedAt(agent);
|
||||||
self.emitState(agent);
|
self.emitState(agent);
|
||||||
|
|
||||||
return streamForwarder;
|
return streamForwarder;
|
||||||
@@ -1527,7 +1538,7 @@ export class AgentManager {
|
|||||||
): void {
|
): void {
|
||||||
// Only update timestamp for live events, not history replay
|
// Only update timestamp for live events, not history replay
|
||||||
if (!options?.fromHistory) {
|
if (!options?.fromHistory) {
|
||||||
agent.updatedAt = new Date();
|
this.touchUpdatedAt(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
let timelineRow: AgentTimelineRow | null = null;
|
let timelineRow: AgentTimelineRow | null = null;
|
||||||
|
|||||||
@@ -579,7 +579,9 @@ export async function createAgentMcpServer(
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
agentManager.recordUserMessage(snapshot.id, trimmedPrompt);
|
agentManager.recordUserMessage(snapshot.id, trimmedPrompt, {
|
||||||
|
emitState: false,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
childLogger.error(
|
childLogger.error(
|
||||||
{ err: error, agentId: snapshot.id },
|
{ err: error, agentId: snapshot.id },
|
||||||
@@ -806,7 +808,9 @@ export async function createAgentMcpServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
agentManager.recordUserMessage(agentId, prompt);
|
agentManager.recordUserMessage(agentId, prompt, {
|
||||||
|
emitState: false,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
childLogger.error(
|
childLogger.error(
|
||||||
{ err: error, agentId },
|
{ err: error, agentId },
|
||||||
|
|||||||
@@ -2311,7 +2311,10 @@ export class Session {
|
|||||||
const prompt = this.buildAgentPrompt(text, images);
|
const prompt = this.buildAgentPrompt(text, images);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.agentManager.recordUserMessage(agentId, text, { messageId });
|
this.agentManager.recordUserMessage(agentId, text, {
|
||||||
|
messageId,
|
||||||
|
emitState: false,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.sessionLogger.error(
|
this.sessionLogger.error(
|
||||||
{ err: error, agentId },
|
{ err: error, agentId },
|
||||||
@@ -5244,7 +5247,10 @@ export class Session {
|
|||||||
await this.interruptAgentIfRunning(agentId);
|
await this.interruptAgentIfRunning(agentId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.agentManager.recordUserMessage(agentId, msg.text, { messageId: msg.messageId });
|
this.agentManager.recordUserMessage(agentId, msg.text, {
|
||||||
|
messageId: msg.messageId,
|
||||||
|
emitState: false,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.sessionLogger.error(
|
this.sessionLogger.error(
|
||||||
{ err: error, agentId },
|
{ err: error, agentId },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
BinaryMuxChannel,
|
BinaryMuxChannel,
|
||||||
TerminalBinaryFlags,
|
TerminalBinaryFlags,
|
||||||
TerminalBinaryMessageType,
|
TerminalBinaryMessageType,
|
||||||
|
asUint8Array,
|
||||||
decodeBinaryMuxFrame,
|
decodeBinaryMuxFrame,
|
||||||
encodeBinaryMuxFrame,
|
encodeBinaryMuxFrame,
|
||||||
} from "./binary-mux.js";
|
} from "./binary-mux.js";
|
||||||
@@ -40,4 +41,10 @@ describe("binary mux frame codec", () => {
|
|||||||
const tampered = encoded.slice(0, encoded.byteLength - 1);
|
const tampered = encoded.slice(0, encoded.byteLength - 1);
|
||||||
expect(decodeBinaryMuxFrame(tampered)).toBeNull();
|
expect(decodeBinaryMuxFrame(tampered)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("converts UTF-8 string payloads to bytes", () => {
|
||||||
|
const bytes = asUint8Array("hello");
|
||||||
|
expect(bytes).not.toBeNull();
|
||||||
|
expect(Array.from(bytes ?? [])).toEqual(Array.from(new TextEncoder().encode("hello")));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,6 +37,19 @@ export interface BinaryMuxFrame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function asUint8Array(data: unknown): Uint8Array | null {
|
export function asUint8Array(data: unknown): Uint8Array | null {
|
||||||
|
if (typeof data === "string") {
|
||||||
|
if (typeof TextEncoder !== "undefined") {
|
||||||
|
return new TextEncoder().encode(data);
|
||||||
|
}
|
||||||
|
if (typeof Buffer !== "undefined") {
|
||||||
|
return new Uint8Array(Buffer.from(data, "utf8"));
|
||||||
|
}
|
||||||
|
const out = new Uint8Array(data.length);
|
||||||
|
for (let i = 0; i < data.length; i += 1) {
|
||||||
|
out[i] = data.charCodeAt(i) & 0xff;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
if (data instanceof Uint8Array) {
|
if (data instanceof Uint8Array) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user