Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 36 additions & 28 deletions frontend/src/pages/agent-detail/AgentDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import ToolsTab from './tabs/ToolsTab';
import AgentDirectory from './AgentDirectory';
import { useAgentDetailRoute } from './hooks/useAgentDetailRoute';
import {
activeRunForSession,
failClosedSessionActiveRun,
mergeTerminalAssistantMessage,
runtimeCompletionNeedsMessageRefresh,
Expand Down Expand Up @@ -2386,6 +2387,7 @@ export default function AgentDetailPage() {
const sessionActiveRunRef = useRef<Record<SessionRuntimeKey, SessionActiveRun | null>>({});
const runtimeEventCursorRef = useRef<Record<SessionRuntimeKey, string>>({});
const [activeRun, setActiveRun] = useState<SessionActiveRun | null>(null);
const selectedSessionActiveRun = activeRunForSession(activeRun, activeSession?.id);
const [reconcilingExecutionId, setReconcilingExecutionId] = useState<string | null>(null);
const [messagesLoadedRuntimeKey, setMessagesLoadedRuntimeKey] = useState<string | null>(null);
const [runtimeStateLoadedRuntimeKey, setRuntimeStateLoadedRuntimeKey] = useState<string | null>(null);
Expand Down Expand Up @@ -2968,8 +2970,8 @@ export default function AgentDetailPage() {
|| (lastChatMessage?.role === 'tool_call' && lastChatMessage.toolStatus === 'running'),
);
const showDirectRunThinking = isWaiting || Boolean(
activeRun
&& ['queued', 'running'].includes(activeRun.status)
selectedSessionActiveRun
&& ['queued', 'running'].includes(selectedSessionActiveRun.status)
&& !isStreaming
&& !hasVisibleLiveProgress
);
Expand Down Expand Up @@ -3701,24 +3703,31 @@ export default function AgentDetailPage() {
};

const dispatchChatMessage = (socket: WebSocket, runtimeKey: SessionRuntimeKey, payload: PendingChatMessage) => {
setIsWaiting(true);
setIsStreaming(false);
const [runtimeAgentId, runtimeSessionId] = runtimeKey.split(':');
const isActiveRuntime = (
currentAgentIdRef.current === runtimeAgentId
&& activeSessionIdRef.current === runtimeSessionId
);
setSessionUiState(runtimeKey, { isWaiting: true, isStreaming: false });
if (payload.resumeRunId) {
const current = sessionActiveRunRef.current[runtimeKey];
if (current?.runId === payload.resumeRunId) {
const next = { ...current, canResume: false };
sessionActiveRunRef.current[runtimeKey] = next;
setActiveRun(next);
if (isActiveRuntime) setActiveRun(next);
}
}
setChatMessages(prev => [...prev, parseChatMsg({
role: 'user',
content: payload.userMsg,
fileName: payload.fileName,
imageUrl: payload.imageUrl,
timestamp: new Date().toISOString()
})]);
if (isActiveRuntime) {
setIsWaiting(true);
setIsStreaming(false);
setChatMessages(prev => [...prev, parseChatMsg({
role: 'user',
content: payload.userMsg,
fileName: payload.fileName,
imageUrl: payload.imageUrl,
timestamp: new Date().toISOString()
})]);
}
socket.send(JSON.stringify({
content: payload.contentForLLM,
display_content: payload.userMsg,
Expand All @@ -3727,7 +3736,6 @@ export default function AgentDetailPage() {
...(payload.resumeRunId ? { run_id: payload.resumeRunId } : {}),
...(payload.resumeCorrelationId ? { correlation_id: payload.resumeCorrelationId } : {}),
}));
const [runtimeAgentId, runtimeSessionId] = runtimeKey.split(':');
window.setTimeout(() => {
void fetchSessionRuntimeState(runtimeAgentId, runtimeSessionId);
}, 250);
Expand All @@ -3740,15 +3748,15 @@ export default function AgentDetailPage() {
reconciliation: ToolReconciliation,
outcome: 'applied' | 'not_applied',
) => {
if (!id || !activeSession?.id || !activeRun?.correlationId) return;
const correlationId = activeRun.correlationId;
if (!id || !activeSession?.id || !selectedSessionActiveRun?.correlationId) return;
const correlationId = selectedSessionActiveRun.correlationId;
const applied = outcome === 'applied';
const confirmation = applied
? t('agent.chat.reconcileAppliedConfirm', '确认该操作已经生效,并且不得重复执行?')
: t('agent.chat.reconcileNotAppliedConfirm', '确认该操作没有生效,可以让 Agent 重新决定是否重试?');
if (!window.confirm(confirmation)) return;

const run = activeRun;
const run = selectedSessionActiveRun;
const sessionId = String(activeSession.id);
const runtimeKey = buildSessionRuntimeKey(id, sessionId);
setReconcilingExecutionId(reconciliation.executionId);
Expand Down Expand Up @@ -3825,14 +3833,14 @@ export default function AgentDetailPage() {
|| !activeSession?.id
|| activeTab !== 'chat'
|| !isWritableSession(activeSession)
|| !activeRun
|| !selectedSessionActiveRun
) return;
const sessionId = String(activeSession.id);
const timer = window.setInterval(() => {
void fetchSessionRuntimeState(id, sessionId);
}, 1500);
return () => window.clearInterval(timer);
}, [id, activeTab, activeSession?.id, activeRun?.runId, activeRun?.status]);
}, [id, activeTab, activeSession?.id, selectedSessionActiveRun?.runId, selectedSessionActiveRun?.status]);

const handleWorkspacePathDeleted = useCallback((path: string) => {
let removedName = '';
Expand Down Expand Up @@ -4722,7 +4730,7 @@ export default function AgentDetailPage() {
messagesLoaded: messagesLoadedRuntimeKey === runtimeKey,
runtimeStateLoaded: runtimeStateLoadedRuntimeKey === runtimeKey,
messageCount: chatMessages.length,
hasActiveRun: activeRun !== null,
hasActiveRun: selectedSessionActiveRun !== null,
})) return;
const pairKey = onboardingKickoffKey(id, String(currentUser.id));
if (onboardingKickoffRef.current.has(pairKey)) return;
Expand All @@ -4736,7 +4744,7 @@ export default function AgentDetailPage() {
kind: 'onboarding_trigger',
model_id: effectiveChatModelId,
}));
}, [wsConnected, id, currentUser?.id, activeSession?.id, agent?.onboarded_for_me, llmModelsLoading, effectiveModelReady, effectiveChatModelId, chatMessages.length, messagesLoadedRuntimeKey, runtimeStateLoadedRuntimeKey, activeRun]);
}, [wsConnected, id, currentUser?.id, activeSession?.id, agent?.onboarded_for_me, llmModelsLoading, effectiveModelReady, effectiveChatModelId, chatMessages.length, messagesLoadedRuntimeKey, runtimeStateLoadedRuntimeKey, selectedSessionActiveRun]);

const { data: permData } = useQuery({
queryKey: ['agent-permissions', id],
Expand Down Expand Up @@ -7089,7 +7097,7 @@ export default function AgentDetailPage() {
) : null}
<div ref={chatInputAreaRef} className="chat-input-area" style={{ flexShrink: 0 }}>
<div className="chat-composer">
{activeRun?.pendingToolReconciliations.map((reconciliation) => (
{selectedSessionActiveRun?.pendingToolReconciliations.map((reconciliation) => (
<div className="chat-tool-reconciliation" key={reconciliation.executionId}>
<div className="chat-tool-reconciliation__title">
<IconAlertTriangle size={16} />
Expand Down Expand Up @@ -7193,7 +7201,7 @@ export default function AgentDetailPage() {
<textarea
ref={chatInputRef}
className="chat-input"
disabled={showNoModelState || !!activeRun?.pendingToolReconciliations.length}
disabled={showNoModelState || !!selectedSessionActiveRun?.pendingToolReconciliations.length}
value={chatInput}
onChange={e => {
setChatInput(e.target.value);
Expand All @@ -7208,7 +7216,7 @@ export default function AgentDetailPage() {
e.key === 'Enter'
&& !e.shiftKey
&& !e.nativeEvent.isComposing
&& !(activeRun?.status === 'waiting_user' && !activeRun.canResume)
&& !(selectedSessionActiveRun?.status === 'waiting_user' && !selectedSessionActiveRun.canResume)
) {
e.preventDefault();
sendChatMsg();
Expand All @@ -7225,7 +7233,7 @@ export default function AgentDetailPage() {
type="button"
className="chat-composer-btn"
onClick={() => fileInputRef.current?.click()}
disabled={showNoModelState || !wsConnected || chatUploadDrafts.length > 0 || attachedFiles.length >= 10 || !!activeRun?.pendingToolReconciliations.length}
disabled={showNoModelState || !wsConnected || chatUploadDrafts.length > 0 || attachedFiles.length >= 10 || !!selectedSessionActiveRun?.pendingToolReconciliations.length}
title={t('agent.workspace.uploadFile')}
>
<IconPaperclip size={16} stroke={1.75} />
Expand All @@ -7237,16 +7245,16 @@ export default function AgentDetailPage() {
disabled={showNoModelState || !wsConnected}
/>
<div style={{ flex: 1 }} />
{activeRun?.canCancel && (
{selectedSessionActiveRun?.canCancel && (
<button
type="button"
className="btn btn-stop-generation"
onClick={() => {
if (!id || !activeSession?.id || !activeRun?.runId) return;
if (!id || !activeSession?.id || !selectedSessionActiveRun?.runId) return;
const activeRuntimeKey = buildSessionRuntimeKey(id, String(activeSession.id));
const activeSocket = wsMapRef.current[activeRuntimeKey];
if (activeSocket?.readyState === WebSocket.OPEN) {
activeSocket.send(JSON.stringify({ type: 'abort', run_id: activeRun.runId }));
activeSocket.send(JSON.stringify({ type: 'abort', run_id: selectedSessionActiveRun.runId }));
}
}}
title={t('chat.stop', 'Stop')}
Expand All @@ -7262,7 +7270,7 @@ export default function AgentDetailPage() {
showNoModelState
|| !wsConnected
|| (!chatInput.trim() && attachedFiles.length === 0)
|| (activeRun?.status === 'waiting_user' && !activeRun.canResume)
|| (selectedSessionActiveRun?.status === 'waiting_user' && !selectedSessionActiveRun.canResume)
}
title={t('chat.send')}
>
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/pages/agent-detail/sessionRuntimeState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ export type SessionActiveRun = {
pendingToolReconciliations: ToolReconciliation[];
};

export const activeRunForSession = (
activeRun: SessionActiveRun | null,
sessionId: unknown,
): SessionActiveRun | null => {
if (!activeRun || sessionId == null) return null;
return activeRun.sessionId === String(sessionId) ? activeRun : null;
};

const record = (value: unknown): Record<string, unknown> | null =>
value !== null && typeof value === 'object'
? value as Record<string, unknown>
Expand Down
8 changes: 7 additions & 1 deletion frontend/tests/directChatObservabilityContract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,18 @@ test('replayed tool packets keep one row by stable tool call id', () => {
});

test('an authoritative active run keeps a thinking indicator visible after reload', () => {
assert.match(source, /\['queued', 'running'\]\.includes\(activeRun\.status\)/);
assert.match(source, /\['queued', 'running'\]\.includes\(selectedSessionActiveRun\.status\)/);
assert.match(source, /showDirectRunThinking/);
assert.match(source, /\{showDirectRunThinking && \(/);
assert.match(source, /lastChatMessage\.toolStatus === 'running'/);
});

test('direct chat runtime controls and delayed sends stay scoped to the selected session', () => {
assert.match(source, /activeRunForSession\(activeRun, activeSession\?\.id\)/);
assert.match(source, /currentAgentIdRef\.current === runtimeAgentId[\s\S]*activeSessionIdRef\.current === runtimeSessionId/);
assert.match(source, /if \(isActiveRuntime\) \{[\s\S]*setIsWaiting\(true\);[\s\S]*setChatMessages/);
});

test('direct chat renders canonical runtime diagnostics and keeps legacy fallbacks', () => {
assert.match(source, /normalizeRuntimeError\(d\)/);
assert.match(source, /formatRuntimeErrorDiagnostics\(msg\.runtimeError\)/);
Expand Down
7 changes: 7 additions & 0 deletions frontend/tests/sessionRuntimeState.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';

import {
activeRunForSession,
failClosedSessionActiveRun,
runtimeCompletionNeedsMessageRefresh,
sessionActiveRunFromResponse,
Expand All @@ -25,6 +26,12 @@ const waitingRun = {
pendingToolReconciliations: [],
};

test('active run controls are projected only onto their own selected session', () => {
assert.equal(activeRunForSession(waitingRun, 'session-2'), null);
assert.equal(activeRunForSession(waitingRun, null), null);
assert.equal(activeRunForSession(waitingRun, 'session-1'), waitingRun);
});

test('runtime-state request failure preserves display identity but disables actions', () => {
assert.deepEqual(failClosedSessionActiveRun(waitingRun), {
...waitingRun,
Expand Down