Skip to content
Open
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
123 changes: 123 additions & 0 deletions src/components/message/activity/activity-rows.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1418,3 +1418,126 @@ describe("a row's figure", () => {
expect(command.querySelector(".activity-figure")).toBeEmptyDOMElement()
})
})

describe("Kimi MCP OAuth entry", () => {
const url =
"https://auth.example.test/authorize?state=a%2Bb&code_challenge=x_y&redirect_uri=http%3A%2F%2F127.0.0.1%3A43127%2Fcallback"
function oauthParts(phase = "waiting", done = false) {
return parts(
turn([
toolUse(
"oauth",
"mcp__notion__authenticate",
{},
{
"codeg.kimiMcpOAuth": {
toolName: "mcp__notion__authenticate",
server: "notion",
phase,
url: phase === "waiting" ? url : null,
},
}
),
...(done ? [toolResult("oauth", "cancelled", true)] : []),
]),
!done,
done ? undefined : ["oauth"]
)
}

it("offers the full URL before completion and opens only on an explicit click", () => {
const open = vi.spyOn(window, "open").mockReturnValue(null)
try {
const view = renderUi(<Round parts={oauthParts()} completed={false} />)
const link = screen.getByRole("link", {
name: "Open authorization in system browser",
})
expect(link).toHaveAttribute("href", url)
expect(open).not.toHaveBeenCalled()
fireEvent.click(link)
expect(open).toHaveBeenCalledExactlyOnceWith(url, "_blank", "noreferrer")
view.rerender(
withProviders(
<Round parts={oauthParts("reconnecting")} completed={false} />
)
)
expect(
screen.queryByRole("link", {
name: "Open authorization in system browser",
})
).not.toBeInTheDocument()
expect(screen.getByText(/Reconnecting notion/)).toBeInTheDocument()
expect(
screen.getByText(/verify access with a real MCP tool call/)
).toBeInTheDocument()
expect(open).toHaveBeenCalledTimes(1)
view.rerender(
withProviders(<Round parts={oauthParts("waiting", true)} />)
)
expect(
screen.queryByText(/Waiting for notion authorization/)
).not.toBeInTheDocument()
} finally {
open.mockRestore()
}
})

it("reports native opener failure without leaking its error and allows retry", async () => {
const platform = await import("@/lib/platform")
const opener = vi
.spyOn(platform, "openUrl")
.mockRejectedValueOnce(new Error("private authorization parameters"))
.mockResolvedValueOnce()
vi.stubGlobal("__TAURI_INTERNALS__", {})
try {
renderUi(<Round parts={oauthParts()} completed={false} />)
const link = screen.getByRole("link", {
name: "Open authorization in system browser",
})
expect(opener).not.toHaveBeenCalled()
await act(async () => {
fireEvent.click(link)
})
expect(opener).toHaveBeenLastCalledWith(url)
expect(screen.getByText(/Could not open the browser/)).toBeInTheDocument()
expect(
screen.queryByText(/private authorization parameters/)
).not.toBeInTheDocument()
expect(link).toHaveAttribute("href", url)
await act(async () => {
fireEvent.click(link)
})
expect(opener).toHaveBeenCalledTimes(2)
expect(
screen.queryByText(/Could not open the browser/)
).not.toBeInTheDocument()
expect(
screen.getByText(/Waiting for notion authorization/)
).toBeInTheDocument()
} finally {
opener.mockRestore()
vi.unstubAllGlobals()
}
})

it("never exposes a link from ordinary MCP output", () => {
renderUi(
<Round
parts={parts(
turn([
toolUse("ordinary", "mcp__notion__fetch", {}),
toolResult(
"ordinary",
`Open this URL in your browser to authorize "notion":\n\n${url}`
),
])
)}
/>
)
expect(
screen.queryByRole("link", {
name: "Open authorization in system browser",
})
).not.toBeInTheDocument()
})
})
36 changes: 36 additions & 0 deletions src/components/message/activity/rows/mcp-oauth-notice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"use client"

import { useState } from "react"
import { useTranslations } from "next-intl"

import { BrowserLink } from "@/components/ui/browser-link"
import type { KimiMcpOAuth } from "@/lib/kimi-mcp-oauth"
import { openInSystemBrowser } from "@/lib/link-open"

/** Opening a browser is only a user action, never proof of authentication. */
export function McpOAuthNotice({ progress }: { progress: KimiMcpOAuth }) {
const t = useTranslations("Folder.chat.activity.oauth")
const [failed, setFailed] = useState(false)
if (progress.phase === "pending") return null
return (
<div className="space-y-2 rounded-lg border p-3 text-sm" role="status">
<p>{t(progress.phase, { server: progress.server })}</p>
{progress.phase === "waiting" && progress.url ? (
<BrowserLink
href={progress.url}
className="text-activity-accent underline underline-offset-4"
onClick={(event) => {
event.preventDefault()
setFailed(false)
// Keep the exact string, including state, PKCE and callback port.
void openInSystemBrowser(progress.url!).catch(() => setFailed(true))
}}
>
{t("open")}
</BrowserLink>
) : null}
<p className="text-xs text-muted-foreground">{t("verify")}</p>
{failed ? <p className="text-tone-danger">{t("openFailed")}</p> : null}
</div>
)
}
14 changes: 13 additions & 1 deletion src/components/message/activity/rows/mcp-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"

import { tryParseJson } from "@/lib/activity/json"
import { mcpResultPayload } from "@/lib/activity/mcp"
import { readKimiMcpOAuth } from "@/lib/kimi-mcp-oauth"
import { cn } from "@/lib/utils"
import { useActivityRender } from "../activity-context"
import { ActivityRow } from "../activity-row"
Expand All @@ -14,6 +15,7 @@ import { RailIcon } from "../rail-icon"
import { ResultBody } from "../result-body"
import { ToolFigure, toolIcon, useStatusText } from "../step-visuals"
import type { ToolStep } from "./explore-rows"
import { McpOAuthNotice } from "./mcp-oauth-notice"

/** `mcp · linear` — where the tool came from, not a status. */
function McpChip({ server }: { server: string }) {
Expand Down Expand Up @@ -43,7 +45,7 @@ function prettyJson(text: string | null): string | null {
export const McpRow = memo(function McpRow({ step }: { step: ToolStep }) {
const t = useTranslations("Folder.chat.activity")
const statusText = useStatusText()
const { recoveredKeys } = useActivityRender()
const { recoveredKeys, isStreaming } = useActivityRender()
const { tool, part } = step
const failed = tool.status === "failed" || tool.status === "rejected"
const [open, setOpen] = useDisclosure(step.key, {
Expand All @@ -60,6 +62,11 @@ export const McpRow = memo(function McpRow({ step }: { step: ToolStep }) {
return JSON.stringify(payload.json, null, 2)
}, [part.output, part.errorText])
const expandable = !!args || !!result
const oauth = readKimiMcpOAuth(part.meta)
const showOAuth =
isStreaming &&
(tool.status === "pending" || tool.status === "running") &&
oauth?.toolName === part.toolName

return (
<ActivityRow
Expand All @@ -78,6 +85,11 @@ export const McpRow = memo(function McpRow({ step }: { step: ToolStep }) {
onExpandedChange={setOpen}
title={tool.detail ?? undefined}
trailing={<ToolFigure tool={tool} />}
addon={
showOAuth && oauth ? (
<McpOAuthNotice key={oauth.url ?? oauth.phase} progress={oauth} />
) : null
}
detail={() => (
<div className="@container">
<div className="grid gap-2 @min-[34rem]:grid-cols-2">
Expand Down
135 changes: 135 additions & 0 deletions src/contexts/acp-connections-context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2520,6 +2520,141 @@ describe("AcpConnectionsProvider permission request details", () => {
}
})

it("exposes Kimi OAuth status before the tool settles and retires its link", async () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
frames.push(cb)
return frames.length
})
vi.stubGlobal("cancelAnimationFrame", () => {})
try {
await mountProvider()
await act(async () => {
await h.actions!.connect(TAB, "kimi_code", "/tmp/x", "sess-1")
})
const handlers = latestAttachHandlers()
let seq = 0
const emit = (event: Omit<EventEnvelope, "seq" | "connection_id">) => {
emitAcpEvent(handlers, {
...event,
seq: ++seq,
connection_id: "spawned-conn",
} as EventEnvelope)
act(() => {
while (frames.length) frames.shift()!(0)
})
}
const call = () => {
const block = h
.store!.getConnection(TAB)!
.liveMessage?.content.find(
(b) => b.type === "tool_call" && b.info.tool_call_id === "auth-1"
)
return block?.type === "tool_call" ? block.info : null
}
emit({ type: "status_changed", status: "prompting" } as EventEnvelope)
emit({
type: "tool_call",
tool_call_id: "auth-1",
title: "mcp__notion__authenticate",
kind: "other",
status: "pending",
content: null,
raw_input: null,
raw_output: null,
} as EventEnvelope)
emit({
type: "tool_call_update",
tool_call_id: "auth-1",
title: "Authenticating notion",
status: "inprogress",
content: null,
raw_input: "{}",
raw_output: null,
} as EventEnvelope)
const url =
"https://auth.example.test/authorize?state=a%2Bb&code_challenge=x_y&redirect_uri=http%3A%2F%2F127.0.0.1%3A43127%2Fcallback"
const progress = `Open this URL in your browser to authorize "notion":\n\n${url}\n\nWaiting for the OAuth callback (timeout 15 min). If you cancel, call this tool again to restart the flow.`
const update = (title: string | null, status: string | null = null) =>
emit({
type: "tool_call_update",
tool_call_id: "auth-1",
title,
status,
content: null,
raw_input: null,
raw_output: null,
} as EventEnvelope)
update(progress)
expect(call()?.status).toBe("inprogress")
expect(call()?.title).toBe("mcp__notion__authenticate")
expect(call()?.meta?.["codeg.kimiMcpOAuth"]).toMatchObject({
phase: "waiting",
url,
})
const { buildStreamingTurnsFromLiveMessage } =
await import("@/stores/conversation-runtime-store")
const { adaptMessageTurn } =
await import("@/lib/adapters/ai-elements-adapter")
const { MOCKUP_ADAPTER_TEXT } =
await import("@/lib/activity/__fixtures__/mockup-turn")
const built = buildStreamingTurnsFromLiveMessage(
42,
h.store!.getConnection(TAB)!.liveMessage!
)
const adapted = adaptMessageTurn(
built.turns[0]!,
MOCKUP_ADAPTER_TEXT,
true,
new Set(["auth-1"])
)
expect(adapted.content.find((p) => p.type === "tool-call")).toMatchObject(
{
toolName: "mcp__notion__authenticate",
meta: { "codeg.kimiMcpOAuth": { phase: "waiting", url } },
}
)
update("Authorized — reconnecting notion…")
expect(call()?.meta?.["codeg.kimiMcpOAuth"]).toMatchObject({
phase: "reconnecting",
url: null,
})
update(null, "failed")
expect(call()?.meta?.["codeg.kimiMcpOAuth"]).toBeNull()
update(progress)
expect(call()?.meta?.["codeg.kimiMcpOAuth"]).toBeNull()
emit({
type: "tool_call_update",
tool_call_id: "late-auth",
title: "mcp__notion__authenticate",
status: "inprogress",
content: null,
raw_input: "{}",
raw_output: null,
meta: {
"codeg.kimiMcpOAuth": {
toolName: "mcp__notion__authenticate",
server: "notion",
phase: "waiting",
url,
},
},
} as EventEnvelope)
const late = h
.store!.getConnection(TAB)!
.liveMessage?.content.find(
(b) => b.type === "tool_call" && b.info.tool_call_id === "late-auth"
)
expect(
late?.type === "tool_call"
? late.info.meta?.["codeg.kimiMcpOAuth"]
: undefined
).toBeNull()
} finally {
vi.unstubAllGlobals()
}
})

it("keeps a Monitor a monitor through the frames that announce it", async () => {
// claude-agent-acp 0.98.0, recorded: the call is announced by its tool
// name with no input, and the input follows in an update that names no
Expand Down
Loading
Loading