diff --git a/index.html b/index.html
index 504cfeb7..52eae395 100644
--- a/index.html
+++ b/index.html
@@ -45,7 +45,7 @@
}
window.APP_DATA = {
- apiUrl: "http://localhost:5002",
+ apiUrl: "",
}
diff --git a/src/frontend/components/ChatMessagesView.tsx b/src/frontend/components/ChatMessagesView.tsx
index 760a8d73..5f92bd63 100644
--- a/src/frontend/components/ChatMessagesView.tsx
+++ b/src/frontend/components/ChatMessagesView.tsx
@@ -604,13 +604,20 @@ export function AIMessageRenderer({ message, pendingInterrupt, onInterruptResume
{toolCall.name}
- {(toolCall as Record
).content != null ? (
+ {(toolCall as Record).status === 'error' ? (
+
+ ) : (toolCall as Record).content != null ? (
) : (
)}
-
Tool execution
+
).status === 'error' ? 'text-red-500 dark:text-red-400' : 'text-muted-foreground',
+ )}>
+ {(toolCall as Record).status === 'error' ? 'Tool execution failed' : 'Tool execution'}
+
{isExpanded ? (
@@ -629,8 +636,15 @@ export function AIMessageRenderer({ message, pendingInterrupt, onInterruptResume
{!needsApproval && (
<>
-
- {(toolCall as Record
).content ? 'Result' : 'Running...'}
+ ).status === 'error' ? 'text-red-500 dark:text-red-400' : 'text-muted-foreground',
+ )}>
+ {(toolCall as Record).status === 'error'
+ ? 'Error'
+ : (toolCall as Record).content != null
+ ? 'Result'
+ : 'Running...'}
{(() => {
const raw = (toolCall as Record).content;
diff --git a/src/frontend/components/InterruptBanner.test.tsx b/src/frontend/components/InterruptBanner.test.tsx
index d72ab150..0b62d0a6 100644
--- a/src/frontend/components/InterruptBanner.test.tsx
+++ b/src/frontend/components/InterruptBanner.test.tsx
@@ -225,6 +225,40 @@ describe('InterruptBanner — MCP auth branch', () => {
expect(screen.queryByRole('button', { name: /continue/i })).not.toBeInTheDocument();
});
+ it('accepts mcp_oauth_done from the OAuth provider origin after Authenticate', async () => {
+ vi.mocked(open).mockReturnValue(window);
+ vi.mocked(fetch)
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ authorize_url: 'https://oauth.example.com/auth' }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ )
+ .mockResolvedValue(
+ new Response(JSON.stringify({ connected: true }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ );
+
+ render();
+ await userEvent.click(screen.getByRole('button', { name: /authenticate/i }));
+ await waitFor(() => expect(vi.mocked(open)).toHaveBeenCalled());
+ expect(screen.queryByText(/popup blocked by browser/i)).not.toBeInTheDocument();
+
+ fireEvent(
+ window,
+ new MessageEvent('message', {
+ data: { type: 'mcp_oauth_done', mcp_name: 'github' },
+ origin: 'https://oauth.example.com',
+ }),
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /continue/i })).toBeInTheDocument();
+ });
+ });
+
it('calls onResume("continue") when Continue is clicked', async () => {
const onResume = vi.fn();
vi.mocked(fetch).mockResolvedValue(
diff --git a/src/frontend/components/InterruptBanner.tsx b/src/frontend/components/InterruptBanner.tsx
index 30ca45df..1899bf94 100644
--- a/src/frontend/components/InterruptBanner.tsx
+++ b/src/frontend/components/InterruptBanner.tsx
@@ -79,6 +79,7 @@ export function InterruptBanner({ interrupt, onResume, onDismiss }: InterruptBan
const [oauthReady, setOauthReady] = useState(false);
const [connecting, setConnecting] = useState(false);
const [connectError, setConnectError] = useState(null);
+ const [oauthOrigin, setOauthOrigin] = useState(null);
const mcpAuth = parseMcpAuthPayload(interrupt);
@@ -94,7 +95,8 @@ export function InterruptBanner({ interrupt, onResume, onDismiss }: InterruptBan
if (!mcpAuth) return undefined;
const handler = (event: MessageEvent) => {
- if (event.origin !== window.location.origin) return;
+ const allowedOrigins = [window.location.origin, oauthOrigin].filter(Boolean);
+ if (allowedOrigins.length > 0 && !allowedOrigins.includes(event.origin)) return;
const data = event.data as { type?: string; mcp_name?: string } | null;
if (data?.type === 'mcp_oauth_done' && data.mcp_name === mcpAuth.mcp_name) {
void verifyAndSetReady(mcpAuth.mcp_name);
@@ -113,10 +115,11 @@ export function InterruptBanner({ interrupt, onResume, onDismiss }: InterruptBan
window.removeEventListener('message', handler);
window.removeEventListener('focus', onFocus);
};
- }, [mcpAuth, oauthReady, verifyAndSetReady]);
+ }, [mcpAuth, oauthReady, oauthOrigin, verifyAndSetReady]);
const handleConnect = useCallback(async () => {
if (!mcpAuth) return;
+ setOauthOrigin(null);
setConnecting(true);
setConnectError(null);
try {
@@ -133,7 +136,9 @@ export function InterruptBanner({ interrupt, onResume, onDismiss }: InterruptBan
if (!body.authorize_url) {
throw new Error('No authorize_url returned');
}
- const popup = window.open(body.authorize_url, 'mcp-oauth', 'width=600,height=700');
+ const authorizeUrl = new URL(body.authorize_url, window.location.origin);
+ setOauthOrigin(authorizeUrl.origin);
+ const popup = window.open(authorizeUrl.href, 'mcp-oauth', 'width=600,height=700');
if (!popup) {
throw new Error('Popup blocked by browser');
}
diff --git a/src/frontend/hooks/useStreamingAPI.ts b/src/frontend/hooks/useStreamingAPI.ts
index 24642916..5d533659 100644
--- a/src/frontend/hooks/useStreamingAPI.ts
+++ b/src/frontend/hooks/useStreamingAPI.ts
@@ -539,6 +539,7 @@ export function useStreamingAPI(threadId: string) {
chatId: threadId,
toolCallId: m.tool_call_id,
content: m.content,
+ status: (m as Record).status as string | undefined,
}),
);
dispatch(
@@ -670,6 +671,7 @@ export function useStreamingAPI(threadId: string) {
}
setWasInterrupted(true);
} else {
+ dispatch(resolveAllPendingToolCalls({ chatId: threadId, status: 'error' }));
dispatch(
updateStreamingState({
chatId: threadId,
@@ -997,7 +999,7 @@ export function useStreamingAPI(threadId: string) {
return;
}
if (m.type === 'tool') {
- dispatch(mergeToolResult({ chatId: threadId, toolCallId: m.tool_call_id, content: m.content }));
+ dispatch(mergeToolResult({ chatId: threadId, toolCallId: m.tool_call_id, content: m.content, status: (m as Record).status as string | undefined }));
dispatch(updateStreamingState({ chatId: threadId, state: { activeSubAgent: null } }));
}
},
@@ -1006,6 +1008,7 @@ export function useStreamingAPI(threadId: string) {
dispatch(updateStreamingState({ chatId: threadId, state: { pendingInterrupt: enrichInterrupt(interrupt) } }));
},
onError(error) {
+ dispatch(resolveAllPendingToolCalls({ chatId: threadId, status: 'error' }));
resumeStreamHadError = true;
// Queue decision to localStorage for replay when agent recovers
let decisionQueued = false;
@@ -1120,6 +1123,7 @@ export function useStreamingAPI(threadId: string) {
chatId: threadId,
toolCallId: m.tool_call_id,
content: m.content,
+ status: (m as Record).status as string | undefined,
}),
);
return;
@@ -1137,6 +1141,7 @@ export function useStreamingAPI(threadId: string) {
);
},
onError(error) {
+ dispatch(resolveAllPendingToolCalls({ chatId: threadId }));
dispatch(
updateStreamingState({
chatId: threadId,
@@ -1178,6 +1183,7 @@ export function useStreamingAPI(threadId: string) {
const stop = useCallback(() => {
userCancelledRef.current = true;
managerRef.current?.cancel();
+ dispatch(resolveAllPendingToolCalls({ chatId: threadId, status: 'cancelled' }));
clearReconnectTimers();
if (recoveryIntervalRef.current) {
clearInterval(recoveryIntervalRef.current);
diff --git a/src/frontend/lib/streaming/StreamingManager.ts b/src/frontend/lib/streaming/StreamingManager.ts
index 0f3bcb30..7e061fa3 100644
--- a/src/frontend/lib/streaming/StreamingManager.ts
+++ b/src/frontend/lib/streaming/StreamingManager.ts
@@ -136,7 +136,7 @@ export class StreamingManager {
try {
const body: Record = {
message: request.resume
- ? { decisions: request.resumeDecisions ?? [] }
+ ? (request.resumeDecisions?.length ? { decisions: request.resumeDecisions } : request.message)
: request.message,
thread_id: request.threadId || 'default-thread',
session_id: request.threadId || 'default-session',
diff --git a/src/frontend/redux/slices/chats.ts b/src/frontend/redux/slices/chats.ts
index 17df3752..c64ecec8 100644
--- a/src/frontend/redux/slices/chats.ts
+++ b/src/frontend/redux/slices/chats.ts
@@ -58,7 +58,7 @@ const initialState: ChatsState = {
error: null,
};
-type ToolCallRecord = { id?: string; content?: unknown };
+type ToolCallRecord = { id?: string; content?: unknown; status?: string };
function deepClone(obj: T): T {
return JSON.parse(JSON.stringify(obj));
@@ -121,8 +121,8 @@ const chatsSlice = createSlice({
}
(last as { content: string }).content = prev + content;
},
- mergeToolResult(state, action: PayloadAction<{ chatId: string; toolCallId: string; content: any }>) {
- const { chatId, toolCallId, content } = action.payload;
+ mergeToolResult(state, action: PayloadAction<{ chatId: string; toolCallId: string; content: any; status?: string }>) {
+ const { chatId, toolCallId, content, status } = action.payload;
const chat = state.chats.find((c) => c.id === chatId);
if (!chat) {
return;
@@ -136,19 +136,24 @@ const chatsSlice = createSlice({
const match = toolCalls.find((tc) => tc?.id === toolCallId);
if (match) {
match.content = content;
+ if (status) {
+ match.status = status;
+ }
return;
}
}
},
- resolveAllPendingToolCalls(state, action: PayloadAction<{ chatId: string }>) {
+ resolveAllPendingToolCalls(state, action: PayloadAction<{ chatId: string; status?: string }>) {
const chat = state.chats.find((c) => c.id === action.payload.chatId);
if (!chat) return;
+ const terminalStatus = action.payload.status || 'error';
for (const message of chat.messages) {
const msg = message as Message & { tool_calls?: ToolCallRecord[] };
if (!Array.isArray(msg.tool_calls)) continue;
for (const tc of msg.tool_calls) {
if (tc && tc.content == null) {
tc.content = '';
+ tc.status = terminalStatus;
}
}
}
diff --git a/src/server/utils/settings.ts b/src/server/utils/settings.ts
index 665d879b..9159b34d 100644
--- a/src/server/utils/settings.ts
+++ b/src/server/utils/settings.ts
@@ -103,6 +103,7 @@ interface BrandingConfig {
interface FeaturesConfig {
debug_mode_default: boolean;
auth_enabled: boolean;
+ mcp_dcr_enabled: boolean;
}
interface AgentConfig {
@@ -156,6 +157,7 @@ const DEFAULTS: UISettings = {
features: {
debug_mode_default: false,
auth_enabled: true,
+ mcp_dcr_enabled: true,
},
agent: {
endpoint: "",
@@ -389,6 +391,9 @@ function applyEnvOverrides(config: UISettings): void {
if (process.env.FEATURE_DEBUG_MODE_DEFAULT !== undefined) {
config.features.debug_mode_default = process.env.FEATURE_DEBUG_MODE_DEFAULT === "true";
}
+ if (process.env.MCP_DCR_ENABLED !== undefined) {
+ config.features.mcp_dcr_enabled = process.env.MCP_DCR_ENABLED === "true";
+ }
// Agent overrides
if (process.env.AGENT_ENDPOINT) {
config.agent.endpoint = process.env.AGENT_ENDPOINT;
diff --git a/vite.config.ts b/vite.config.ts
index 9d6d7548..5245b904 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -4,6 +4,8 @@ import react from "@vitejs/plugin-react-swc";
import tailwindcss from "@tailwindcss/vite";
// https://vitejs.dev/config/
+const proxyTarget = `http://127.0.0.1:${process.env.PORT || 8080}`;
+
export default defineConfig({
plugins: [react(), tailwindcss()],
build: {
@@ -29,15 +31,15 @@ export default defineConfig({
server: {
proxy: {
"/api": {
- target: "http://127.0.0.1:8080",
+ target: proxyTarget,
changeOrigin: true,
},
"/auth": {
- target: "http://127.0.0.1:8080",
+ target: proxyTarget,
changeOrigin: true,
},
"/login": {
- target: "http://127.0.0.1:8080",
+ target: proxyTarget,
changeOrigin: true,
},
},