Skip to content
Merged
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
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
}

window.APP_DATA = {
apiUrl: "http://localhost:5002",
apiUrl: "",
}
</script>
<script type="module" src="/src/frontend/main.tsx"></script>
Expand Down
22 changes: 18 additions & 4 deletions src/frontend/components/ChatMessagesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -604,13 +604,20 @@ export function AIMessageRenderer({ message, pendingInterrupt, onInterruptResume
<div className="text-left">
<div className="text-sm font-medium text-foreground flex items-center gap-2">
<code className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">{toolCall.name}</code>
{(toolCall as Record<string, unknown>).content != null ? (
{(toolCall as Record<string, unknown>).status === 'error' ? (
<AlertCircle className="w-4 h-4 text-red-500 dark:text-red-400" aria-hidden="true" />
) : (toolCall as Record<string, unknown>).content != null ? (
<CheckCircle className="w-4 h-4 text-green-500 dark:text-green-400" aria-hidden="true" />
) : (
<Loader2 className="w-4 h-4 text-primary animate-spin" aria-hidden="true" />
)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">Tool execution</div>
<div className={cn(
"text-xs mt-0.5",
(toolCall as Record<string, unknown>).status === 'error' ? 'text-red-500 dark:text-red-400' : 'text-muted-foreground',
)}>
{(toolCall as Record<string, unknown>).status === 'error' ? 'Tool execution failed' : 'Tool execution'}
</div>
</div>
</div>
{isExpanded ? (
Expand All @@ -629,8 +636,15 @@ export function AIMessageRenderer({ message, pendingInterrupt, onInterruptResume
</pre>
{!needsApproval && (
<>
<div className="text-xs font-medium text-muted-foreground mb-2 mt-3 uppercase tracking-wider">
{(toolCall as Record<string, unknown>).content ? 'Result' : 'Running...'}
<div className={cn(
"text-xs font-medium mb-2 mt-3 uppercase tracking-wider",
(toolCall as Record<string, unknown>).status === 'error' ? 'text-red-500 dark:text-red-400' : 'text-muted-foreground',
)}>
{(toolCall as Record<string, unknown>).status === 'error'
? 'Error'
: (toolCall as Record<string, unknown>).content != null
? 'Result'
: 'Running...'}
</div>
{(() => {
const raw = (toolCall as Record<string, unknown>).content;
Expand Down
34 changes: 34 additions & 0 deletions src/frontend/components/InterruptBanner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<InterruptBanner interrupt={mcpAuthInterrupt} onResume={vi.fn()} onDismiss={vi.fn()} />);
await userEvent.click(screen.getByRole('button', { name: /authenticate/i }));
await waitFor(() => expect(vi.mocked(open)).toHaveBeenCalled());
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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(
Expand Down
11 changes: 8 additions & 3 deletions src/frontend/components/InterruptBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
const [oauthOrigin, setOauthOrigin] = useState<string | null>(null);

const mcpAuth = parseMcpAuthPayload(interrupt);

Expand All @@ -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);
Expand All @@ -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 {
Expand All @@ -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');
}
Expand Down
8 changes: 7 additions & 1 deletion src/frontend/hooks/useStreamingAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,7 @@
chatId: threadId,
toolCallId: m.tool_call_id,
content: m.content,
status: (m as Record<string, unknown>).status as string | undefined,
}),
);
dispatch(
Expand Down Expand Up @@ -670,6 +671,7 @@
}
setWasInterrupted(true);
} else {
dispatch(resolveAllPendingToolCalls({ chatId: threadId, status: 'error' }));
dispatch(
updateStreamingState({
chatId: threadId,
Expand Down Expand Up @@ -900,7 +902,7 @@
}, RECOVERY_POLL_INTERVAL_MS);
}
},
[dispatch, threadId, memories, activeRules, handleStreamActivityStatus, clearReconnectTimers],

Check warning on line 905 in src/frontend/hooks/useStreamingAPI.ts

View workflow job for this annotation

GitHub Actions / lint

React Hook useCallback has missing dependencies: 'setMessages' and 'setWasInterrupted'. Either include them or remove the dependency array
);

/**
Expand Down Expand Up @@ -997,7 +999,7 @@
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<string, unknown>).status as string | undefined }));
dispatch(updateStreamingState({ chatId: threadId, state: { activeSubAgent: null } }));
}
},
Expand All @@ -1006,6 +1008,7 @@
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;
Expand Down Expand Up @@ -1120,6 +1123,7 @@
chatId: threadId,
toolCallId: m.tool_call_id,
content: m.content,
status: (m as Record<string, unknown>).status as string | undefined,
}),
);
return;
Expand All @@ -1137,6 +1141,7 @@
);
},
onError(error) {
dispatch(resolveAllPendingToolCalls({ chatId: threadId }));
dispatch(
updateStreamingState({
chatId: threadId,
Expand Down Expand Up @@ -1178,6 +1183,7 @@
const stop = useCallback(() => {
userCancelledRef.current = true;
managerRef.current?.cancel();
dispatch(resolveAllPendingToolCalls({ chatId: threadId, status: 'cancelled' }));
clearReconnectTimers();
if (recoveryIntervalRef.current) {
clearInterval(recoveryIntervalRef.current);
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/lib/streaming/StreamingManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export class StreamingManager {
try {
const body: Record<string, unknown> = {
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',
Expand Down
13 changes: 9 additions & 4 deletions src/frontend/redux/slices/chats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/server/utils/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ interface BrandingConfig {
interface FeaturesConfig {
debug_mode_default: boolean;
auth_enabled: boolean;
mcp_dcr_enabled: boolean;
}

interface AgentConfig {
Expand Down Expand Up @@ -156,6 +157,7 @@ const DEFAULTS: UISettings = {
features: {
debug_mode_default: false,
auth_enabled: true,
mcp_dcr_enabled: true,
},
agent: {
endpoint: "",
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 5 additions & 3 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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,
},
},
Expand Down
Loading