diff --git a/ui/playwright/tests/chat/agent-sharing.spec.ts b/ui/playwright/tests/chat/agent-sharing.spec.ts index 05221c63a..ee60117ef 100644 --- a/ui/playwright/tests/chat/agent-sharing.spec.ts +++ b/ui/playwright/tests/chat/agent-sharing.spec.ts @@ -4,19 +4,9 @@ import { agentChat, instances } from "../../helpers/app"; /** * Sharing a conversation: create a link, see it listed, revoke it, open one. * - * ## What changed to make this possible - * * A share is over an `AgentInstance`, because the instance *is* the conversation. - * `AgentInstanceService` always carried the three share RPCs, but nothing on the - * read path honoured the token they minted: the gRPC interceptor resolved - * `X-Share-Token` through `GetSessionShareByToken` and produced a context naming a - * *session*, while the A2A gateway authorises on the instance. A dialog built on - * those RPCs would have handed out links that could not be opened — which is why - * this was deferred rather than shipped. - * - * The interceptor now tries both kinds of share, and the gateway reads the instance - * as the share's *owner* when the token names it — which it must, since an instance - * is scoped to its creator and reading it as the visitor finds nothing. + * The gRPC interceptor validates the share token, and the A2A gateway reads the + * instance as the share's owner; reading it as the visitor would find nothing. * * ## What a fixture can and cannot prove here * @@ -149,4 +139,3 @@ test("sharing: a link that allows replies offers a way to reply", async ({ page await expect(page.getByTestId("chat-input")).toBeVisible(); }); - diff --git a/ui/playwright/tests/chat/shared-conversation.spec.ts b/ui/playwright/tests/chat/shared-conversation.spec.ts deleted file mode 100644 index dc57b1c9a..000000000 --- a/ui/playwright/tests/chat/shared-conversation.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { expect, test } from "../../fixtures/test"; - -/** - * Opening a conversation somebody shared. - * - * ## What is left of sharing, and why - * - * Only this half. The controller's share RPCs identify a *session*, and the gRPC - * interceptor that honours `X-Share-Token` resolves it through - * `GetSessionShareByToken` — so a share is a capability over a session and nothing - * else. Chat is addressed by `AgentInstance` now, and while - * `AgentInstanceService` does carry `CreateAgentInstanceShare`, nothing on the read - * path validates the token it mints: the A2A gateway authorises on the instance - * instead. A Share button would therefore hand somebody a link that cannot be - * opened, which is worse than no button. - * - * So the UI mints no tokens, and this spec covers what remains true: a link issued - * before still opens, still says what it is, and still stops working when revoked. - * `playwright/DEFERRED.md` records what it would take to share a conversation again. - * - * The token is seeded in `src/mocks/state.ts` rather than created through the UI, - * because there is no longer a control that creates one — the fixture equivalent of - * a link somebody was sent last week. - */ - -const TOKEN = "mock-share-token-1"; -const SESSION = "session-8f31"; -const SHARED = `/shared/${SESSION}/${TOKEN}`; - -test("shared conversation: a link issued earlier opens, read-only", async ({ page }) => { - await test.step("1. it shows the conversation and says what it is", async () => { - await page.goto(SHARED); - - // Said on the page, not only in the URL. A reader who was sent a link has no - // other way to know that this is somebody else's conversation, or why there is - // nowhere to reply. - await expect(page.getByTestId("shared-session-notice")).toContainText("read-only", { - timeout: 30_000, - }); - await expect(page.getByTestId("shared-session-transcript")).toBeVisible(); - await expect(page.getByTestId("shared-session-error")).toHaveCount(0); - }); - - await test.step("2. the transcript is the shared conversation's own", async () => { - // The seeded session's turns, read through `sessions.tasks` — not the A2A - // gateway, which knows nothing about sessions. A page that rendered an empty - // transcript would look like a working share of an empty conversation. - await expect(page.getByTestId("chat-message").first()).toBeVisible({ - timeout: 30_000, - }); - }); - - await test.step("3. there is no composer, because a share is for reading", async () => { - // An input that could not send would be worse than none. - await expect(page.getByTestId("chat-input")).toHaveCount(0); - }); -}); - -test("shared conversation: a token the backend never issued is refused", async ({ - page, -}) => { - // The claim this makes is about the header being sent, not about the page - // rendering: the fixture backend refuses a token it cannot resolve, exactly as the - // controller does, so a build that stopped sending `X-Share-Token` would serve an - // unauthenticated read and the miss would read on screen as success. - await page.goto(`/shared/${SESSION}/not-a-real-token`); - - await expect(page.getByTestId("shared-session-error")).toBeVisible({ - timeout: 30_000, - }); - await expect(page.getByTestId("shared-session-transcript")).toHaveCount(0); -}); diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 526b49c00..4ed89f5bd 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -33,13 +33,6 @@ import type { PromptTemplateSummary, UpdatePromptTemplateRequest, } from "./domain/prompts"; -import type { - CreateSessionRequest, - CreateSessionShareRequest, - Session, - SessionShare, -} from "./domain/sessions"; -import type { ChatMessage } from "./chat/types"; import type { NamespaceResponse } from "./domain/namespaces"; import type { SubstrateActorPage, @@ -156,37 +149,6 @@ export interface PromptsApi { remove(namespace: string, name: string): Promise; } -export interface SessionsApi { - listForAgent( - namespace: string, - name: string, - options?: ReadOptions, - ): Promise; - get(id: string, options?: ReadOptions): Promise; - create(payload: CreateSessionRequest): Promise; - remove(id: string): Promise; - /** - * Share links for a conversation. - * - * Only the owner may list or revoke: the controller checks the session belongs - * to the caller before doing either, so a `NotFound` here means "not yours" as - * much as "not there". - */ - shares: { - list(id: string, options?: ReadOptions): Promise; - create(id: string, payload?: CreateSessionShareRequest): Promise; - remove(id: string, token: string): Promise; - }; - /** - * The turns held in a session, as messages to render. - * - * Only the shared-conversation page reads this now: live chat is addressed by - * `AgentInstance` and replays itself from the A2A gateway. A share token names a - * session, though, so the links already issued still resolve through here. - */ - tasks(id: string, options?: ReadOptions): Promise; -} - export interface NamespacesApi { list(options?: ReadOptions): Promise; } @@ -371,7 +333,6 @@ export interface KagentApiClient { models: ModelsApi; mcpServers: McpServersApi; prompts: PromptsApi; - sessions: SessionsApi; namespaces: NamespacesApi; substrate: SubstrateApi; agentInstances: AgentInstancesApi; @@ -428,20 +389,6 @@ export function createApiClient(): KagentApiClient { remove: (namespace, name) => invoke("prompts.delete", { namespace, name }), }, - sessions: { - listForAgent: (namespace, name, options) => - invoke("sessions.listForAgent", { namespace, name }, options), - get: (id, options) => invoke("sessions.get", { id }, options), - create: (payload) => invoke("sessions.create", { payload }), - remove: (id) => invoke("sessions.delete", { id }), - shares: { - list: (id, options) => invoke("sessions.shares.list", { id }, options), - create: (id, payload) => invoke("sessions.shares.create", { id, payload }), - remove: (id, token) => invoke("sessions.shares.delete", { id, token }), - }, - tasks: (id, options) => invoke("sessions.tasks", { id }, options), - }, - namespaces: { list: (options) => invoke("namespaces.list", {}, options), }, diff --git a/ui/src/api/domain/agentTemplates.ts b/ui/src/api/domain/agentTemplates.ts index f01b17512..a537baa35 100644 --- a/ui/src/api/domain/agentTemplates.ts +++ b/ui/src/api/domain/agentTemplates.ts @@ -41,13 +41,12 @@ export interface ConfigMapKeyRef { /** * Tools selected from one MCP server. * - * `tools` is required and must hold at least one name — unlike the tool bindings on - * the older `Agent` kind, where an empty list meant *every* tool the server - * exposes. The CRD here has `MinItems=1`, so "all of them" has to be spelled out. + * An omitted or empty `tools` list exposes every tool the server provides. A + * non-empty list limits the binding to those names. */ export interface McpToolBinding { server: { kind: "RemoteMCPServer"; name: string }; - tools: string[]; + tools?: string[]; } /** Another AgentTemplate exposed to this one as a tool it can route work to. */ diff --git a/ui/src/api/domain/sessions.ts b/ui/src/api/domain/sessions.ts deleted file mode 100644 index b12be8831..000000000 --- a/ui/src/api/domain/sessions.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Session domain models. - * - * A session is one conversation with an agent. Its message history lives behind - * the chat client port (see `api/chat`), not here — this is only the record. - */ - -export interface Session { - id: string; - name: string; - /** `namespace__NS__name` of the agent this conversation belongs to. */ - agent_id: string; - user_id: string; - created_at: string; - updated_at: string; - deleted_at: string; - /** Set for sessions owned by another user; sent back as the share token. */ - share_token?: string | null; - /** True when the share link that granted access is read-only. */ - share_read_only?: boolean | null; -} - -/** - * A share link for a conversation. - * - * Shaped from `SessionShare` in `go/api/database/models.go` rather than from the old UI's - * own copy: the controller is what answers, and the two had drifted in casing. - */ -export interface SessionShare { - id: number; - /** The capability the link carries. Sent back as `x-share-token`. */ - token: string; - session_id: string; - /** Who created it. Only the owner may list or revoke. */ - user_id: string; - read_only: boolean; - created_at: string; -} - -export interface CreateSessionShareRequest { - /** - * Omitted means read-only. - * - * The controller defaults it to `true` and treats `false` as a deliberate opt-in to - * read-write, which is the right way round for something that hands out access. - */ - read_only?: boolean; -} - -export interface CreateSessionRequest { - agent_ref?: string; - name?: string; - id?: string; -} diff --git a/ui/src/api/grpc/operations.ts b/ui/src/api/grpc/operations.ts index 975ba8627..94c01b388 100644 --- a/ui/src/api/grpc/operations.ts +++ b/ui/src/api/grpc/operations.ts @@ -36,8 +36,7 @@ * `ListProviderModels` (refresh one provider's catalogue), * `ListSupportedMemoryProviders`, `ListToolServerTypes`, the MCP-app RPCs * (`ListMCPAppTools`, `CallMCPAppTool`, `ReadMCPAppResource`), the - * `AgentHarness` session-actor RPCs, `SessionService.ListSessions`, - * `UpdateSession`, `AddSessionEvent`, and `SystemService`'s `GetVersion` and + * `AgentHarness` session-actor RPCs and `SystemService`'s `GetVersion` and * `GetCurrentUser` all exist on the controller and have no operation id, because * nothing in the app calls them yet. Adding one is a new id here, not a new path * anywhere else. @@ -56,10 +55,6 @@ import { AgentKind, AgentService } from "@/generated/kagent/api/v1alpha1/agents_ import { ModelService } from "@/generated/kagent/api/v1alpha1/models_pb"; import { ToolService } from "@/generated/kagent/api/v1alpha1/tools_pb"; import { PromptTemplateService } from "@/generated/kagent/api/v1alpha1/prompts_pb"; -import { - SessionService, - TaskStoreService, -} from "@/generated/kagent/api/v1alpha1/sessions_pb"; import { SystemService } from "@/generated/kagent/api/v1alpha1/system_pb"; import { HarnessService } from "@/generated/kagent/api/v1alpha1/harnesses_pb"; import type { Harness as PbHarness } from "@/generated/kagent/api/v1alpha1/harnesses_pb"; @@ -74,8 +69,6 @@ import { import type { AgentInstanceShare as PbAgentInstanceShare } from "@/generated/kagent/api/v1alpha1/agent_instances_pb"; import type { AgentInstance as PbAgentInstance } from "@/generated/kagent/api/v1alpha1/agent_instances_pb"; import type { Agent as PbAgent } from "@/generated/kagent/api/v1alpha1/agents_pb"; -import type { Session as PbSession } from "@/generated/kagent/api/v1alpha1/sessions_pb"; -import type { SessionShare as PbSessionShare } from "@/generated/kagent/api/v1alpha1/sessions_pb"; import type { ToolServer as PbToolServer } from "@/generated/kagent/api/v1alpha1/tools_pb"; import type { GetSubstrateStatusResponse, @@ -87,7 +80,6 @@ import type { import type { StructuredObject } from "@/generated/kagent/api/v1alpha1/common_pb"; import { ApiError, fromConnectError, isNotFound, rethrowIfAborted } from "../ApiError"; import { operationContext, serviceClient } from "../transport"; -import { messagesFromTask } from "../chat/a2aGrpcChatClient"; import { KAGENT_API_VERSION, isoFrom, @@ -112,7 +104,6 @@ import type { ToolsResponse, } from "../domain/mcpServers"; import type { PromptTemplateDetail, PromptTemplateSummary } from "../domain/prompts"; -import type { Session, SessionShare } from "../domain/sessions"; import type { SubstrateActorEntry, SubstrateActorTemplateEntry, @@ -748,168 +739,6 @@ function toPromptDetail(template: { // endregion -// region Sessions - -/** - * One session record, in the snake-cased shape the chat client and the pages read. - * - * The names are the database model's, not the proto's, and they are kept because - * `Session.agent_id` carries the `namespace__NS__name` string the chat client - * splits on — renaming the field would mean touching every reader for no gain. - * - * Timestamps come back as `google.protobuf.Timestamp` and go out as RFC3339 - * strings, which is what everything that formats a date here expects. A session - * that has not been deleted has no `deleted_at` at all rather than the epoch: the - * epoch renders as "1 January 1970" on a screen that only checks for truthiness. - */ -function toSession(session: PbSession): Session { - return { - id: session.id, - name: session.name ?? "", - agent_id: session.agentId ?? "", - user_id: session.userId, - created_at: isoFrom(session.createdAt), - updated_at: isoFrom(session.updatedAt), - deleted_at: isoFrom(session.deletedAt), - share_token: session.shareToken ?? null, - share_read_only: session.shareReadOnly ?? null, - }; -} - -function toShare(share: PbSessionShare): SessionShare { - return { - // `int64` in the proto, so protobuf-es hands over a bigint. See `toNumber` for - // why that must not reach the app, and for the full inventory of these fields. - id: toNumber(share.id), - token: share.token, - session_id: share.sessionId, - user_id: share.userId, - read_only: share.readOnly, - created_at: isoFrom(share.createdAt), - }; -} - -const sessions: Pick< - ApiOperations, - | "sessions.listForAgent" - | "sessions.get" - | "sessions.create" - | "sessions.delete" - | "sessions.tasks" - | "sessions.shares.list" - | "sessions.shares.create" - | "sessions.shares.delete" -> = { - "sessions.listForAgent": async (input, options) => { - const response = await rpc("SessionService/ListSessionsByAgent", options.signal, () => - serviceClient(SessionService).listSessionsByAgent( - { agentRef: { namespace: input.namespace, name: input.name } }, - call("sessions.listForAgent", options), - ), - ); - return list(response.sessions).map(toSession); - }, - - "sessions.get": async (input, options) => { - const name = "SessionService/GetSession"; - const response = await rpc(name, options.signal, () => - serviceClient(SessionService).getSession( - { sessionId: input.id }, - call("sessions.get", options), - ), - ); - const session = toSession( - required(response.session, name, `session ${input.id}`), - ); - // `read_only` is reported beside the session rather than on it: it describes - // the *caller's* access, which is a property of this read and not of the - // record. It is folded in because that is where every reader looks for it, and - // only when the record itself is silent. - return session.share_read_only === null && response.readOnly !== undefined - ? { ...session, share_read_only: response.readOnly } - : session; - }, - - "sessions.create": async (input, options) => { - const name = "SessionService/CreateSession"; - const response = await rpc(name, options.signal, () => - serviceClient(SessionService).createSession( - { - id: input.payload.id, - agentRef: input.payload.agent_ref ?? "", - name: input.payload.name, - }, - call("sessions.create", options), - ), - ); - return toSession(required(response.session, name, "created session")); - }, - - "sessions.delete": async (input, options) => { - await rpc("SessionService/DeleteSession", options.signal, () => - serviceClient(SessionService).deleteSession( - { sessionId: input.id }, - call("sessions.delete", options), - ), - ); - }, - - "sessions.tasks": async (input, options) => { - const response = await rpc("TaskStoreService/ListTasks", options.signal, () => - serviceClient(TaskStoreService).listTasks( - { sessionId: input.id }, - call("sessions.tasks", options), - ), - ); - // Read straight out of the proto, by the same function the live stream uses for - // a task frame. There used to be a translation to A2A JSON here, because the - // chat client parsed JSON off an SSE body and the two bindings of A2A disagree - // in three places — a `Part` is a oneof in the proto and a tagged object in - // JSON, enums are spelled differently, and the timestamp is a different type. - // With chat on gRPC both sides are proto, so the translation had nothing left to - // bridge and the file that did it is gone. - return list(response.tasks).flatMap(messagesFromTask); - }, - - "sessions.shares.list": async (input, options) => { - const response = await rpc("SessionService/ListSessionShares", options.signal, () => - serviceClient(SessionService).listSessionShares( - { sessionId: input.id }, - call("sessions.shares.list", options), - ), - ); - return list(response.shares).map(toShare); - }, - - "sessions.shares.create": async (input, options) => { - const name = "SessionService/CreateSessionShare"; - const response = await rpc(name, options.signal, () => - serviceClient(SessionService).createSessionShare( - { - sessionId: input.id, - // Sent explicitly. The field is `optional bool`, so leaving it out means - // "the controller decides" — and it decides read-only, which is the right - // default for handing out access but not a thing to leave implicit. - readOnly: input.payload?.read_only ?? true, - }, - call("sessions.shares.create", options), - ), - ); - return toShare(required(response.share, name, "created share")); - }, - - "sessions.shares.delete": async (input, options) => { - await rpc("SessionService/DeleteSessionShare", options.signal, () => - serviceClient(SessionService).deleteSessionShare( - { sessionId: input.id, token: input.token }, - call("sessions.shares.delete", options), - ), - ); - }, -}; - -// endregion - // region Agent instances /** @@ -1678,7 +1507,6 @@ export const defaultOperations: ApiOperations = { ...models, ...toolServers, ...prompts, - ...sessions, ...agentInstances, ...cluster, }; diff --git a/ui/src/api/grpc/wire.ts b/ui/src/api/grpc/wire.ts index 090bbc3d3..2b8745c96 100644 --- a/ui/src/api/grpc/wire.ts +++ b/ui/src/api/grpc/wire.ts @@ -115,7 +115,6 @@ export function isoFrom(timestamp: Timestamp | undefined): string { * awk '/^message /{m=$2} /int64|uint64/{print FILENAME":"NR" "m}' \ * proto/kagent/api/v1alpha1/*.proto * - * - `sessions.proto:68` — `SessionShare.id` (reached by `sessions.shares.*`) * - `system.proto:85` — `SubstrateActor.version` (reached by `substrate.status`) * - `system.proto:96` — `SubstrateWorker.version` (reached by `substrate.status`) * - `feedback.proto:15` — `Feedback.id` (no operation id yet) diff --git a/ui/src/api/hooks/useSessionTranscript.ts b/ui/src/api/hooks/useSessionTranscript.ts deleted file mode 100644 index 907488b8d..000000000 --- a/ui/src/api/hooks/useSessionTranscript.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * A session's transcript, read once and not written to. - * - * This exists for share links, and only for them. Chat itself moved onto - * `AgentInstance` — the instance is the conversation, and `useChat` is addressed - * that way — but a share token identifies a *session*, and the tokens already - * issued still have to resolve. So the two are separate: `useChat` is the live - * conversation, and this is a record of one that was had. - * - * ## Why it is not `useChat` with sending switched off - * - * Because `useChat` speaks to the A2A gateway, which routes on the instance - * headers and knows nothing about sessions. Asking it for a session's history - * would address the wrong thing. The session's turns come from - * `SessionService`/`TaskStoreService` instead, through `sessions.tasks`, which - * reads the stored A2A tasks and hands back messages. - * - * ## Why it returns a `ChatController` - * - * So `ChatTranscript` renders it. The component takes a controller and this is - * one, with the three write operations answering immediately and doing nothing: - * a share is read-only, and the page above never offers a control that would call - * them. Stubbing them here rather than widening the component's contract keeps - * "what a transcript needs" in one shape. - */ - -import { useEffect, useMemo, useState } from "react"; -import { apiClient } from "../client"; -import type { ChatMessage } from "../chat/types"; -import type { ChatController } from "./useChat"; - -/** Nothing to do: a shared conversation cannot be written to. */ -const NO_OP = async (): Promise => {}; -const NO_MESSAGES: ChatMessage[] = []; - -export function useSessionTranscript(sessionId: string | undefined): ChatController { - const [state, setState] = useState<{ - sessionId: string; - messages?: ChatMessage[]; - error?: Error; - } | null>(null); - - useEffect(() => { - if (!sessionId) return; - const controller = new AbortController(); - - apiClient.sessions - .tasks(sessionId, { signal: controller.signal }) - .then((tasks) => { - if (controller.signal.aborted) return; - setState({ sessionId, messages: tasks }); - }) - .catch((cause: unknown) => { - if (controller.signal.aborted) return; - setState({ - sessionId, - error: cause instanceof Error ? cause : new Error(String(cause)), - }); - }); - - return () => controller.abort(); - }, [sessionId]); - - // Tagged with whose transcript it is and derived for the one being viewed, the - // same way `useChat` does it: cleared by an effect instead, a render would - // happen first and show the previous conversation under the new one's heading. - const mine = state?.sessionId === sessionId ? state : null; - - return useMemo( - () => ({ - messages: mine?.messages ?? NO_MESSAGES, - isLoadingHistory: Boolean(sessionId) && mine === null, - historyError: mine?.error, - turnError: undefined, - turnState: "idle" as const, - // A read-only replay has no turn to be in a phase of. - turnPhase: "idle" as const, - phase: "idle" as const, - send: NO_OP, - cancel: NO_OP, - dismissQuestion: NO_OP, - answerQuestion: NO_OP, - retry: NO_OP, - // A shared *session* is a record of a conversation that has finished, read - // through the task store rather than the gateway. Nothing writes to it while it - // is open, so there is nothing to re-read for. - refreshTranscript: NO_OP, - }), - [mine, sessionId], - ); -} diff --git a/ui/src/api/hooks/useSessions.ts b/ui/src/api/hooks/useSessions.ts deleted file mode 100644 index 856d3faed..000000000 --- a/ui/src/api/hooks/useSessions.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { apiClient } from "../client"; -import type { Session } from "../domain/sessions"; -import { type ApiResource, useApiResource } from "./useApiResource"; - -/** Conversations belonging to one agent. Holds off until the ref is known. */ -export function useAgentSessions( - namespace: string | undefined, - name: string | undefined, -): ApiResource { - return useApiResource( - namespace && name ? ["sessions.listForAgent", namespace, name] : null, - () => apiClient.sessions.listForAgent(namespace!, name!), - ); -} - -/** One conversation's record. Its messages come from the chat client, not here. */ -export function useSession(id: string | undefined): ApiResource { - return useApiResource(id ? ["sessions.get", id] : null, () => - apiClient.sessions.get(id!), - ); -} diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index 6f45ea012..865d50644 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -24,7 +24,6 @@ export type { PromptsApi, SubstrateApi, ReadOptions, - SessionsApi, } from "./client"; export { invoke, operationIds } from "./operations"; @@ -77,7 +76,6 @@ export * from "./domain/models"; export * from "./domain/namespaces"; export * from "./domain/substrate"; export * from "./domain/prompts"; -export * from "./domain/sessions"; export * from "./domain/harnesses"; export * from "./domain/agentTemplates"; export * from "./domain/agentPairs"; @@ -91,7 +89,6 @@ export { useProviders, } from "./hooks/useModels"; export { usePrompt, usePrompts } from "./hooks/usePrompts"; -export { useAgentSessions, useSession } from "./hooks/useSessions"; export { useNamespaces } from "./hooks/useNamespaces"; export { useSubstrateActors, @@ -107,7 +104,6 @@ export { useHarnesses, useHarnessesAcrossNamespaces, } from "./hooks/useAgentBuildingBlocks"; -export { useSessionTranscript } from "./hooks/useSessionTranscript"; export { useAgentConversations, useAgentInstance, diff --git a/ui/src/api/operations.test.ts b/ui/src/api/operations.test.ts index bc21aacc1..8bb3d4730 100644 --- a/ui/src/api/operations.test.ts +++ b/ui/src/api/operations.test.ts @@ -30,22 +30,16 @@ import { AgentKind, AgentService } from "@/generated/kagent/api/v1alpha1/agents_ import { ModelService } from "@/generated/kagent/api/v1alpha1/models_pb"; import { ToolService } from "@/generated/kagent/api/v1alpha1/tools_pb"; import { PromptTemplateService } from "@/generated/kagent/api/v1alpha1/prompts_pb"; -import { - SessionService, - TaskStoreService, -} from "@/generated/kagent/api/v1alpha1/sessions_pb"; import { SystemService } from "@/generated/kagent/api/v1alpha1/system_pb"; import { AgentInstanceOperation as PbAgentInstanceOperation, AgentInstanceService, AgentInstanceState as PbAgentInstanceState, } from "@/generated/kagent/api/v1alpha1/agent_instances_pb"; -import { Role, TaskState } from "@/generated/a2a_pb"; import { ApiError, isNotFound } from "./ApiError"; import { apiClient } from "./client"; import { clearApiExtensions } from "./extensionPoints"; import { registerAuthTokenSource, setApiTransport } from "./transport"; -import { withShareToken } from "./shareToken"; import { registerApiTransform } from "./extensionPoints"; /** Installs in-process services as the transport the client calls through. */ @@ -664,148 +658,6 @@ describe("prompt libraries", () => { }); }); -describe("sessions", () => { - const sessionMessage = { - id: "sess-1", - name: "why is checkout crashlooping?", - userId: "someone@example.test", - agentId: "kagent__NS__k8s-agent", - createdAt: { seconds: 1767225600n, nanos: 0 }, - updatedAt: { seconds: 1767225600n, nanos: 0 }, - }; - - /** - * Timestamps arrive as `google.protobuf.Timestamp` and every renderer wants - * RFC3339. A session that has not been deleted gets no `deleted_at` at all - * rather than the epoch, which would render as "1 January 1970" on a screen that - * only checks the field for truthiness. - */ - it("renders proto timestamps as RFC3339 and leaves an unset one empty", async () => { - serve(({ service }) => { - service(SessionService, { getSession: () => ({ session: sessionMessage }) }); - }); - - const session = await apiClient.sessions.get("sess-1"); - expect(session.created_at).toBe("2026-01-01T00:00:00.000Z"); - expect(session.deleted_at).toBe(""); - expect(session.agent_id).toBe("kagent__NS__k8s-agent"); - }); - - /** - * `read_only` is reported beside the session rather than on it, because it - * describes the caller's access rather than the record. Folded in because that is - * where every reader looks for it. - */ - it("folds the caller's read-only access onto the record", async () => { - serve(({ service }) => { - service(SessionService, { - getSession: () => ({ session: sessionMessage, readOnly: true }), - }); - }); - - expect((await apiClient.sessions.get("sess-1")).share_read_only).toBe(true); - }); - - it("lists a share and reads its int64 id as a number", async () => { - serve(({ service }) => { - service(SessionService, { - listSessionShares: () => ({ - shares: [ - { - id: 42n, - token: "tok-abc", - sessionId: "sess-1", - userId: "someone@example.test", - readOnly: true, - createdAt: { seconds: 1767225600n, nanos: 0 }, - }, - ], - }), - }); - }); - - const [share] = await apiClient.sessions.shares.list("sess-1"); - expect(share.id).toBe(42); - expect(share.token).toBe("tok-abc"); - expect(share.read_only).toBe(true); - }); - - // The controller defaults an omitted `read_only` to true, which is the right way - // round for handing out access — but it is sent explicitly rather than left to a - // default nobody reading this code can see. - it("asks for a read-only share unless told otherwise", async () => { - const asked: Array = []; - serve(({ service }) => { - service(SessionService, { - createSessionShare: (request) => { - asked.push(request.readOnly); - return { - share: { - id: 1n, - token: "t", - sessionId: request.sessionId, - userId: "u", - readOnly: request.readOnly ?? true, - }, - }; - }, - }); - }); - - await apiClient.sessions.shares.create("sess-1"); - await apiClient.sessions.shares.create("sess-1", { read_only: false }); - expect(asked).toEqual([true, false]); - }); - - /** - * The trap this operation exists to avoid, restated for the proto path. - * - * A `Part` is a **oneof** in the proto — `{content: {case: "text", value}}` — with - * no `kind` field anywhere, so a reader that tests `part.kind === "text"` finds - * nothing and a replayed conversation comes back empty with no error. Reading the - * oneof is what makes the transcript appear, and this is the assertion that would - * catch it going wrong. - */ - it("reads the transcript out of the proto rather than testing for a `kind`", async () => { - serve(({ service }) => { - service(TaskStoreService, { - listTasks: () => ({ - tasks: [ - { - id: "task-1", - contextId: "sess-1", - status: { - state: TaskState.COMPLETED, - timestamp: { seconds: 1767225600n, nanos: 0 }, - }, - history: [ - { - messageId: "m1", - role: Role.USER, - parts: [{ content: { case: "text" as const, value: "hello" } }], - }, - ], - artifacts: [], - }, - ], - }), - }); - }); - - const messages = await apiClient.sessions.tasks("sess-1"); - - expect(messages).toHaveLength(1); - expect(messages[0].id).toBe("m1"); - expect(messages[0].role).toBe("user"); - expect(messages[0].parts).toEqual([{ kind: "text", text: "hello" }]); - // The task's own instant, not the clock now: history messages carry no time of - // their own, so stamping them with the moment the conversation was reopened - // reads as true and is wrong by however long ago the conversation was. - expect(messages[0].createdAt).toBe("2026-01-01T00:00:00.000Z"); - expect(messages[0].taskId).toBe("task-1"); - }); -}); - describe("the cluster", () => { it("lists namespaces with their phase", async () => { serve(({ service }) => { @@ -889,30 +741,6 @@ describe("the cluster", () => { * registry. */ describe("transforms reaching the wire", () => { - it("carries a share token into the call's metadata", async () => { - const seen: Array = []; - serve(({ service }) => { - service(SessionService, { - getSession: (_request, context) => { - seen.push(context.requestHeader.get("X-Share-Token")); - return { session: { id: "sess-1", userId: "u" } }; - }, - }); - }); - - registerApiTransform({ - name: "shareToken", - request: (context) => withShareToken(context, "sess-1", "tok-abc"), - }); - - await apiClient.sessions.get("sess-1"); - await apiClient.sessions.get("sess-2"); - - // The token belongs to one conversation, and only that conversation's call - // gets it. - expect(seen).toEqual(["tok-abc", null]); - }); - /** * The interceptors live above the transport rather than inside it precisely so a * substituted one cannot skip them. This is the test that says so: the transport diff --git a/ui/src/api/operations.ts b/ui/src/api/operations.ts index ffd097bdd..550c6c57b 100644 --- a/ui/src/api/operations.ts +++ b/ui/src/api/operations.ts @@ -50,13 +50,6 @@ import type { PromptTemplateSummary, UpdatePromptTemplateRequest, } from "./domain/prompts"; -import type { - CreateSessionRequest, - CreateSessionShareRequest, - Session, - SessionShare, -} from "./domain/sessions"; -import type { ChatMessage } from "./chat/types"; import type { NamespaceResponse } from "./domain/namespaces"; import type { SubstrateActorPage, @@ -200,26 +193,6 @@ export interface OperationMap { }; "prompts.delete": { input: ResourceRefInput; output: void }; - "sessions.listForAgent": { input: ResourceRefInput; output: Session[] }; - "sessions.get": { input: { id: string }; output: Session }; - "sessions.create": { input: { payload: CreateSessionRequest }; output: Session }; - "sessions.delete": { input: { id: string }; output: void }; - /** - * The turns held in a session, for replaying a shared conversation. - * - * `ChatMessage[]` rather than the A2A documents this used to hand back. Chat - * itself no longer goes through sessions — an `AgentInstance` is the - * conversation — so the only caller left is the shared-conversation page, which - * wants messages to render and never wanted the protocol. - */ - "sessions.tasks": { input: { id: string }; output: ChatMessage[] }; - "sessions.shares.list": { input: { id: string }; output: SessionShare[] }; - "sessions.shares.create": { - input: { id: string; payload?: CreateSessionShareRequest }; - output: SessionShare; - }; - "sessions.shares.delete": { input: { id: string; token: string }; output: void }; - /** * Every agent instance in one namespace. * diff --git a/ui/src/api/shareToken.test.ts b/ui/src/api/shareToken.test.ts index 233154ce7..c1c50b3d2 100644 --- a/ui/src/api/shareToken.test.ts +++ b/ui/src/api/shareToken.test.ts @@ -1,87 +1,62 @@ import { describe, expect, it } from "vitest"; -import { withShareToken } from "./shareToken"; +import { withInstanceShareToken } from "./shareToken"; import type { ApiCallId, ApiRequestContext } from "./extensionPoints"; -/** - * A share token is a credential for one conversation. What matters here is not - * that it is sent, but that it is sent to precisely the calls it belongs on: too - * narrow and a shared link shows an empty conversation, too wide and every - * unrelated operation is handed a token for a session it was never asked about. - * - * The scoping test used to be on the URL path, and had to guard against - * `/sessions/sess-1` matching `/sessions/sess-1-and-more`. It is now on the - * operation id and the session named in the request message, so the identity is - * compared as a whole value and the prefix problem does not exist. The cases below - * are the same properties as before, asked of the new mechanism. - */ - -const context = ( - call: ApiCallId, - message?: unknown, -): ApiRequestContext => ({ - endpoint: call, +const context = (endpoint: ApiCallId, message?: unknown): ApiRequestContext => ({ + endpoint, method: "POST", - url: "/api/kagent.api.v1alpha1.SessionService/GetSession", - headers: { Accept: "application/json" }, + url: "/api/kagent.api.v1alpha1.AgentInstanceService/GetAgentInstance", + headers: { Accept: "application/grpc-web+proto" }, message, }); -const header = (call: ApiCallId, message?: unknown) => - withShareToken(context(call, message), "sess-1", "tok-abc").headers["X-Share-Token"]; - -describe("withShareToken", () => { - it("sends the token when reading the conversation itself", () => { - expect(header("sessions.get", { sessionId: "sess-1" })).toBe("tok-abc"); - }); +const header = (endpoint: ApiCallId, message?: unknown) => + withInstanceShareToken( + context(endpoint, message), + "kagent", + "instance-1", + "tok-abc", + ).headers["X-Share-Token"]; - it("sends it when reading that conversation's turns", () => { - expect(header("sessions.tasks", { sessionId: "sess-1" })).toBe("tok-abc"); +describe("withInstanceShareToken", () => { + it("attaches the token to reads and allowed lifecycle calls for its instance", () => { + const message = { namespace: "kagent", agentInstanceId: "instance-1" }; + expect(header("agentInstances.get", message)).toBe("tok-abc"); + expect(header("agentInstances.suspend", message)).toBe("tok-abc"); + expect(header("agentInstances.resume", message)).toBe("tok-abc"); }); - it("sends it for that conversation's share links", () => { - expect(header("sessions.shares.list", { sessionId: "sess-1" })).toBe("tok-abc"); + it("does not attach the token to another instance or namespace", () => { expect( - header("sessions.shares.delete", { sessionId: "sess-1", token: "other" }), - ).toBe("tok-abc"); - }); - - it("leaves other conversations alone", () => { - expect(header("sessions.get", { sessionId: "sess-2" })).toBeUndefined(); - }); - - // The whole class of failure the old path matching had to defend against: an id - // this one is a prefix of is a different conversation. - it("is not fooled by an id this one is a prefix of", () => { - expect(header("sessions.get", { sessionId: "sess-1-and-more" })).toBeUndefined(); - }); - - it("leaves the rest of the app alone", () => { - expect(header("agents.list", {})).toBeUndefined(); - expect(header("sessions.listForAgent", { namespace: "kagent", name: "k8s" })) - .toBeUndefined(); - }); - - /** - * A visitor with a read-only link must not be able to post to the agent, and a - * visitor with a read-write one is authorised by their own identity rather than - * by this token. Either way chat is not something the token belongs on. - */ - it("never attaches to the conversation's A2A endpoint", () => { - expect(header("chat.a2a", { sessionId: "sess-1" })).toBeUndefined(); - }); - - it("declines a session call that names no session", () => { - expect(header("sessions.get")).toBeUndefined(); - expect(header("sessions.get", { sessionId: 7 })).toBeUndefined(); - }); - - it("keeps the headers the request already had", () => { - const result = withShareToken( - context("sessions.get", { sessionId: "sess-1" }), - "sess-1", + header("agentInstances.get", { + namespace: "kagent", + agentInstanceId: "instance-2", + }), + ).toBeUndefined(); + expect( + header("agentInstances.get", { + namespace: "other", + agentInstanceId: "instance-1", + }), + ).toBeUndefined(); + }); + + it("does not attach the token to destructive or unrelated operations", () => { + const message = { namespace: "kagent", agentInstanceId: "instance-1" }; + expect(header("agentInstances.delete", message)).toBeUndefined(); + expect(header("models.list", {})).toBeUndefined(); + }); + + it("preserves existing headers", () => { + const result = withInstanceShareToken( + context("agentInstances.get", { + namespace: "kagent", + agentInstanceId: "instance-1", + }), + "kagent", + "instance-1", "tok-abc", ); - - expect(result.headers.Accept).toBe("application/json"); + expect(result.headers.Accept).toBe("application/grpc-web+proto"); }); }); diff --git a/ui/src/api/shareToken.ts b/ui/src/api/shareToken.ts index 43c8b5838..bb7d8cbf9 100644 --- a/ui/src/api/shareToken.ts +++ b/ui/src/api/shareToken.ts @@ -14,109 +14,16 @@ import type { ApiCallId, ApiRequestContext } from "./extensionPoints"; */ const SHARE_HEADER = "X-Share-Token"; -/** - * The operations that are about one conversation. - * - * Enumerated rather than pattern-matched. Under REST this was a check on the URL - * path, and that check had a trap in it — `/sessions/{id}` is a prefix of - * `/sessions/{id}-and-more`, which is a different conversation — so the test had to - * be about where the segment *ended*. An operation id has no prefixes, so the whole - * class of mistake is gone. - * - * `chat.a2a` is deliberately not here. A visitor holding a read-only link must not - * be able to post to the agent, and the token is not what authorises them to when - * the link is read-write — their own identity is. - */ -const SESSION_OPERATIONS = new Set([ - "sessions.get", - "sessions.tasks", - "sessions.shares.list", - "sessions.shares.create", - "sessions.shares.delete", -]); - -/** - * Adds the token to calls about one conversation, and to nothing else. - * - * Scoped rather than sent on everything, because the token is a credential for - * exactly one conversation and the visitor is signed in as themselves for the rest - * of the app. A transform that attached it to every call would hand a session - * token to operations that have no business seeing it — and, the part that - * actually bites, a stale registration would keep doing so after the reader had - * navigated away. - * - * The session is read from the request message rather than from the address, so a - * call about a *different* conversation is left alone even though it is the same - * operation. - */ -export function withShareToken( - context: ApiRequestContext, - sessionId: string, - token: string, -): ApiRequestContext { - if (!SESSION_OPERATIONS.has(context.endpoint)) return context; - if (sessionIdOf(context.message) !== sessionId) return context; - - return { ...context, headers: { ...context.headers, [SHARE_HEADER]: token } }; -} - -/** - * The conversation a request message names. - * - * Every session RPC carries it as `session_id`, which the generated TypeScript - * spells `sessionId`. A message without one is not about a conversation, so it - * gets no token. - */ -function sessionIdOf(message: unknown): string | undefined { - if (!message || typeof message !== "object") return undefined; - const value = (message as { sessionId?: unknown }).sessionId; - return typeof value === "string" ? value : undefined; -} - -/** - * Spends a share token for as long as the component is mounted. - * - * Registered on mount and removed on cleanup, so the credential lives exactly as - * long as the page that was opened with it — and never outlives it, which a - * module-level token would. - * - * ## Why a layout effect, and why the call order matters - * - * A *passive* effect is too late. SWR triggers its first read from a layout effect, - * and layout effects all run before any passive one — so the transcript request - * went out before the token was registered, without the header, and the backend - * answered as though nobody had a share. It looked like it worked: the fixture - * served the conversation to an anonymous read, and only asking the mock to - * *refuse* an unknown token showed the header was missing. - * - * Within one component, effects of the same kind run in the order their hooks were - * called. So this must be called *above* whatever reads the conversation, and being - * a layout effect is what puts it ahead of SWR's own. - */ -export function useShareToken(sessionId: string | undefined, token: string | undefined) { - useLayoutEffect(() => { - if (!sessionId || !token) return; - - return registerApiTransform({ - name: "shareToken", - request: (context) => withShareToken(context, sessionId, token), - }); - }, [sessionId, token]); -} - /* - * The AgentInstance half of sharing. + * AgentInstance sharing. * - * A share over an instance cannot go through the transform chain the session one - * uses, and the reason is worth stating rather than working around twice: the A2A - * calls are made against the generated `A2AService` client directly and carry no - * operation id, so `transformInterceptor` passes them straight through. Giving them - * one would mean inventing operation ids for a client that is deliberately not part - * of the operation table. + * A2A calls cannot go through the ordinary operation transform chain: they are made + * against the generated `A2AService` client directly and carry no operation id, so + * `transformInterceptor` passes them straight through. Giving them one would mean + * inventing operation ids for a client deliberately outside the operation table. * - * So the token is registered here and the chat client reads it. One registration - * point and one header name for both kinds of share — the alternative, a second - * mechanism beside this one, is exactly the drift this file already warns about. + * So the token is registered here and the chat client reads it. The ordinary + * AgentInstance operations use the same registration and header below. */ /** Which conversation a registered instance share is for. */ diff --git a/ui/src/api/transport.ts b/ui/src/api/transport.ts index 444c3329e..37e755b84 100644 --- a/ui/src/api/transport.ts +++ b/ui/src/api/transport.ts @@ -33,11 +33,9 @@ * The fixtures therefore need no transform handling of their own and cannot drift * from this. * - * The concrete thing that protects: `withShareToken` sets `X-Share-Token` through - * a request transform, and the sharing spec asserts the backend refuses a token it - * never issued. If that header stopped reaching the transport, an unauthenticated - * read would be served and the missing header would read as success — which has - * happened here before. + * The concrete thing that protects: AgentInstance sharing sets `X-Share-Token` + * through a request transform. If that header stopped reaching the transport, a + * shared conversation would be refused even though its ordinary API calls worked. */ import { diff --git a/ui/src/components/agent-template-form/AgentTemplateForm.tsx b/ui/src/components/agent-template-form/AgentTemplateForm.tsx index 207b8b99f..50a9be249 100644 --- a/ui/src/components/agent-template-form/AgentTemplateForm.tsx +++ b/ui/src/components/agent-template-form/AgentTemplateForm.tsx @@ -336,7 +336,7 @@ export function AgentTemplateForm({ {/* Tools — MCP servers */} {readOnly && draft.mcpTools.length === 0 @@ -384,7 +384,7 @@ export function AgentTemplateForm({ : {})} value={tool.tools} loading={tools.isLoading} - placeholder={placeholder("Tools")} + placeholder={placeholder("All tools")} popupMatchSelectWidth={false} onChange={(value: string[]) => { const next = [...draft.mcpTools]; diff --git a/ui/src/components/agent-template-form/agentTemplateDraft.test.ts b/ui/src/components/agent-template-form/agentTemplateDraft.test.ts index 8cd2b53da..8bb79dbc8 100644 --- a/ui/src/components/agent-template-form/agentTemplateDraft.test.ts +++ b/ui/src/components/agent-template-form/agentTemplateDraft.test.ts @@ -134,17 +134,32 @@ describe("the agent template draft", () => { expect(toInline).not.toHaveProperty("systemPromptFrom"); }); - it("drops a tool binding the CRD would refuse", () => { + it("keeps an MCP binding with no selection because it exposes every server tool", () => { const draft = emptyDraft("kagent"); draft.modelConfig = "gpt"; - // `MinItems=1` on the CRD: a server with nothing selected is rejected, so it is - // not sent. Unlike the older Agent kind, an empty list does not mean "all". draft.mcpTools = [{ serverRef: "kagent/tools", tools: [] }]; draft.agentTools = [ { name: "", description: "", templateName: "other", isolation: "Shared" }, ]; - expect(specFromDraft(draft)).not.toHaveProperty("tools"); + expect(specFromDraft(draft).tools).toEqual([ + { + mcp: { + server: { kind: "RemoteMCPServer", name: "tools" }, + }, + }, + ]); + }); + + it("round-trips an MCP binding that exposes every server tool", () => { + const template = templateWithExtras(); + template.resource.spec.tools = [ + { mcp: { server: { kind: "RemoteMCPServer", name: "tools" } } }, + ]; + + const spec = specFromDraft(draftFromTemplate(template), template.resource.spec); + + expect(spec.tools).toEqual(template.resource.spec.tools); }); it("sends an MCP server by bare name, as the CRD's reference is same-namespace", () => { diff --git a/ui/src/components/agent-template-form/agentTemplateDraft.ts b/ui/src/components/agent-template-form/agentTemplateDraft.ts index be91fb35d..89de70c87 100644 --- a/ui/src/components/agent-template-form/agentTemplateDraft.ts +++ b/ui/src/components/agent-template-form/agentTemplateDraft.ts @@ -28,7 +28,7 @@ import type { export interface McpToolDraft { /** `namespace/name` of the RemoteMCPServer, as the tool list reports it. */ serverRef: string; - /** The tool names selected. The CRD requires at least one. */ + /** The tool names selected. Empty means every tool the server exposes. */ tools: string[]; } @@ -135,14 +135,14 @@ export function specFromDraft( ): AgentTemplateSpec { const tools: ToolBinding[] = [ ...draft.mcpTools - // A binding with no tools selected is rejected by the CRD (`MinItems=1`), so - // an empty row is dropped here rather than sent to be refused. - .filter((tool) => tool.serverRef.trim() !== "" && tool.tools.length > 0) + // A named server with no explicit selection exposes all of its tools. Only an + // unfinished row with no server is dropped. + .filter((tool) => tool.serverRef.trim() !== "") .map((tool) => ({ mcp: { // The only kind the CRD's enum allows. server: { kind: "RemoteMCPServer" as const, name: bareName(tool.serverRef) }, - tools: [...tool.tools], + ...(tool.tools.length > 0 ? { tools: [...tool.tools] } : {}), }, })), ...draft.agentTools diff --git a/ui/src/components/chat/AgentContextPanel.test.tsx b/ui/src/components/chat/AgentContextPanel.test.tsx new file mode 100644 index 000000000..1ebb20324 --- /dev/null +++ b/ui/src/components/chat/AgentContextPanel.test.tsx @@ -0,0 +1,62 @@ +import { render, screen } from "@testing-library/react"; +import { ThemeProvider } from "@emotion/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import { themeFor } from "@/theme/theme"; +import { AgentContextPanel } from "./AgentContextPanel"; + +const useAgentTemplate = vi.hoisted(() => vi.fn()); + +vi.mock("@/api", () => ({ useAgentTemplate })); + +function renderPanel(tools: unknown[]) { + useAgentTemplate.mockReturnValue({ + data: { + resource: { + spec: { + modelConfig: { name: "gpt" }, + tools, + }, + }, + }, + isLoading: false, + error: undefined, + }); + + render( + + + + + , + ); +} + +describe("AgentContextPanel", () => { + it("renders an MCP binding whose optional tools list is omitted", () => { + renderPanel([ + { + mcp: { + server: { kind: "RemoteMCPServer", name: "tools" }, + }, + }, + ]); + + expect(screen.getByText("tools (all tools)")).toBeTruthy(); + }); + + it("renders explicitly selected MCP tools by name", () => { + renderPanel([ + { + mcp: { + server: { kind: "RemoteMCPServer", name: "tools" }, + tools: ["list_pods"], + }, + }, + ]); + + expect(screen.getByText("list_pods")).toBeTruthy(); + }); +}); diff --git a/ui/src/components/chat/AgentContextPanel.tsx b/ui/src/components/chat/AgentContextPanel.tsx index 662b4c8d5..0c8cf9e43 100644 --- a/ui/src/components/chat/AgentContextPanel.tsx +++ b/ui/src/components/chat/AgentContextPanel.tsx @@ -118,11 +118,17 @@ export function AgentContextPanel({ {tools.flatMap((binding, index) => binding.mcp - ? binding.mcp.tools.map((tool) => ( - - {tool} - - )) + ? binding.mcp.tools?.length + ? binding.mcp.tools.map((tool) => ( + + {tool} + + )) + : [ + + {binding.mcp.server.name} (all tools) + , + ] : binding.agent ? [ diff --git a/ui/src/components/chat/ShareDialog.tsx b/ui/src/components/chat/ShareDialog.tsx index 740a19b51..d030adcfe 100644 --- a/ui/src/components/chat/ShareDialog.tsx +++ b/ui/src/components/chat/ShareDialog.tsx @@ -14,16 +14,8 @@ const { Text } = Typography; * * An `AgentInstance`. The instance *is* the conversation — the A2A gateway files * every task under it as the task's `contextId` — so sharing one hands somebody what - * was said. Shares used to be over a chat session, and the RPCs, the token's - * validation and the link have all moved with the conversation itself. - * - * That move needed a server-side change to be honest, and it is worth saying which: - * `AgentInstanceService` always carried the three share RPCs, but nothing on the - * read path validated the token they minted — the interceptor resolved - * `X-Share-Token` through `GetSessionShareByToken` and produced a context naming a - * *session*, while the gateway authorises on the instance. So this dialog could have - * existed at any point and would have handed out links that could not be opened. It - * exists now because the interceptor understands both kinds of share. + * was said. The gRPC interceptor validates the instance token, and the A2A gateway + * authorises that same instance as the share's owner. * * ## A token is shown once, and the list never shows one * diff --git a/ui/src/mocks/fixtures.ts b/ui/src/mocks/fixtures.ts index 4824f18ad..0ad0d7807 100644 --- a/ui/src/mocks/fixtures.ts +++ b/ui/src/mocks/fixtures.ts @@ -15,7 +15,6 @@ import type { ProviderModelsResponse, } from "@/api/domain/models"; import type { PromptTemplateDetail, PromptTemplateSummary } from "@/api/domain/prompts"; -import type { Session } from "@/api/domain/sessions"; import type { NamespaceResponse } from "@/api/domain/namespaces"; import type { SubstrateStatusResponse } from "@/api/domain/substrate"; import type { Harness } from "@/api/domain/harnesses"; @@ -451,27 +450,6 @@ export const mockPromptDetails: Record = { }, }; -export const mockSessions: Session[] = [ - { - id: "session-8f31", - name: "Why is checkout crashlooping?", - agent_id: "kagent__NS__k8s-agent", - user_id: "admin@kagent.dev", - created_at: "2026-07-28T09:12:00Z", - updated_at: "2026-07-28T09:31:00Z", - deleted_at: "", - }, - { - id: "session-2b07", - name: "Node pressure on the analytics pool", - agent_id: "kagent__NS__k8s-agent", - user_id: "admin@kagent.dev", - created_at: "2026-07-29T14:02:00Z", - updated_at: "2026-07-29T14:20:00Z", - deleted_at: "", - }, -]; - /** The namespaces the fixtures' agents and models actually live in, plus a couple more. */ export const mockNamespaces: NamespaceResponse[] = [ { name: "kagent", status: "Active" }, diff --git a/ui/src/mocks/mockBackend.test.ts b/ui/src/mocks/mockBackend.test.ts index 8fbfc66c2..66e9cb109 100644 --- a/ui/src/mocks/mockBackend.test.ts +++ b/ui/src/mocks/mockBackend.test.ts @@ -15,10 +15,9 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { ApiError } from "@/api/ApiError"; -import { clearApiExtensions, registerApiTransform } from "@/api/extensionPoints"; +import { clearApiExtensions } from "@/api/extensionPoints"; import { invoke, operationIds } from "@/api/operations"; import type { OperationId, OperationInput } from "@/api/operations"; -import { withShareToken } from "@/api/shareToken"; import { setApiTransport } from "@/api/transport"; import { mockTransport } from "./transport"; import { MOCK_INSTANCE_CREATOR } from "./fixtures"; @@ -109,15 +108,6 @@ const INPUTS = { }, "prompts.delete": { namespace: "kagent", name: "swept-prompts" }, - "sessions.listForAgent": { namespace: "kagent", name: "k8s-agent" }, - "sessions.get": { id: "session-8f31" }, - "sessions.create": { payload: { agent_ref: "kagent/k8s-agent", name: "swept" } }, - "sessions.delete": { id: "session-2b07" }, - "sessions.tasks": { id: "session-8f31" }, - "sessions.shares.list": { id: "session-8f31" }, - "sessions.shares.create": { id: "session-8f31" }, - "sessions.shares.delete": { id: "session-8f31", token: "mock-share-token-1" }, - /* * Each of these acts on a different instance, because this sweep runs every * operation concurrently and the controller refuses a second lifecycle operation @@ -321,55 +311,6 @@ describe("the fixture backend", () => { expect(all.length).toBeGreaterThan(scoped.length); }); - it("lists only the conversations belonging to the agent asked about", async () => { - const mine = await invoke("sessions.listForAgent", { - namespace: "kagent", - name: "k8s-agent", - }); - expect(mine.length).toBeGreaterThan(0); - - const someone_elses = await invoke("sessions.listForAgent", { - namespace: "platform", - name: "incident-commander", - }); - expect(someone_elses).toEqual([]); - }); - - describe("share links", () => { - /* - * The token is only a capability because the fake refuses one it did not issue. - * A fixture that served the conversation to anybody would make "the link works" - * a claim about the page rendering and nothing else. - */ - it("refuses a token it never issued and honours one it did", async () => { - const share = await invoke("sessions.shares.create", { id: "session-8f31" }); - - spend("session-8f31", "not-a-real-token"); - await expect(invoke("sessions.get", { id: "session-8f31" })).rejects.toMatchObject({ - status: 403, - }); - - clearApiExtensions(); - spend("session-8f31", share.token); - const session = await invoke("sessions.get", { id: "session-8f31" }); - // Reported beside the record, because it describes this caller's access. - expect(session.share_read_only).toBe(true); - - clearApiExtensions(); - await invoke("sessions.shares.delete", { id: "session-8f31", token: share.token }); - const remaining = await invoke("sessions.shares.list", { id: "session-8f31" }); - expect(remaining.map((row) => row.token)).not.toContain(share.token); - }); - - /** Registers the production transform, so the header travels the real way. */ - function spend(sessionId: string, token: string): void { - registerApiTransform({ - name: "shareToken", - request: (context) => withShareToken(context, sessionId, token), - }); - } - }); - /* * The fixture backend has to refuse a lifecycle operation for the same reasons the * controller does, or the disabled buttons on the instances page are decoration: diff --git a/ui/src/mocks/state.ts b/ui/src/mocks/state.ts index 8aafa11a2..d452c02f4 100644 --- a/ui/src/mocks/state.ts +++ b/ui/src/mocks/state.ts @@ -31,7 +31,6 @@ import type { import type { Harness } from "@/api/domain/harnesses"; import type { AgentTemplate } from "@/api/domain/agentTemplates"; import { admitsLabels } from "@/api/domain/harnesses"; -import type { Session, SessionShare } from "@/api/domain/sessions"; import { mockAgentInstances, mockAgentTemplates, @@ -41,7 +40,6 @@ import { mockModels, mockPromptDetails, mockPrompts, - mockSessions, } from "./fixtures"; /** What has been written during this browsing session. */ @@ -50,7 +48,6 @@ const created = { models: [] as ModelConfig[], mcpServers: [] as ToolServerResponse[], prompts: [] as PromptTemplateDetail[], - sessions: [] as Session[], agentInstances: [] as AgentInstance[], agentTemplates: [] as AgentTemplate[], harnesses: [] as Harness[], @@ -280,110 +277,6 @@ export function savePrompt(detail: PromptTemplateDetail): PromptTemplateDetail { return detail; } -// --------------------------------------------------------------------------- -// Sessions -// --------------------------------------------------------------------------- - -export function allSessions(): Session[] { - return [...mockSessions, ...created.sessions].filter((row) => isLive(row.id)); -} - -export function saveSession(request: { - id?: string; - agentRef?: string; - name?: string; -}): Session { - const now = new Date().toISOString(); - const session: Session = { - id: request.id || `session-${created.sessions.length + 1}-mock`, - name: request.name ?? "New conversation", - // The `namespace__NS__name` form the chat client splits on, from the - // `namespace/name` ref a create sends. - agent_id: (request.agentRef || "kagent/k8s-agent").replace("/", "__NS__"), - user_id: "admin@kagent.dev", - created_at: now, - updated_at: now, - deleted_at: "", - }; - created.sessions.push(session); - return session; -} - -// --------------------------------------------------------------------------- -// Share links -// --------------------------------------------------------------------------- - -/** - * Share links created in this tab. - * - * In `sessionStorage` rather than a module variable, which is what the other - * writes here use. A share link is spent by *opening* it, and opening it is a full - * page load — so a module variable meant the token stopped existing at the moment - * it was used, and the one flow this fixture exists to support was the one flow it - * could not serve. Still per-tab and still gone when the tab closes, so nothing - * leaks between runs. - */ -const SHARES_KEY = "kagent.mock.shares"; - -/** - * A share that was issued before this tab opened. - * - * Seeded because nothing in the UI mints one any more: chat is addressed by - * `AgentInstance`, and an instance share would hand out a token no read path - * validates (the interceptor resolves `X-Share-Token` through - * `GetSessionShareByToken` and produces a share context naming a *session*). The - * links already issued still have to open, so this is one of them — the fixture - * equivalent of a link somebody was sent last week. - */ -export const SEEDED_SHARE: SessionShare = { - id: 1, - token: "mock-share-token-1", - session_id: "session-8f31", - user_id: "alice", - read_only: true, - created_at: "2026-08-01T09:00:00Z", -}; - -export function readShares(): SessionShare[] { - try { - const stored = window.sessionStorage.getItem(SHARES_KEY); - // Absent, not empty: a tab that has revoked the seeded share stores `[]`, and - // re-seeding it there would make a revoke impossible to observe. - if (stored === null) return [SEEDED_SHARE]; - return JSON.parse(stored) as SessionShare[]; - } catch { - return []; - } -} - -function writeShares(rows: SessionShare[]): void { - try { - window.sessionStorage.setItem(SHARES_KEY, JSON.stringify(rows)); - } catch { - // Storage can be refused. The list is then empty for this load, which is the - // same answer as a tab that has created nothing. - } -} - -export function createShare(sessionId: string, readOnly: boolean): SessionShare { - const existing = readShares(); - const share: SessionShare = { - id: existing.length + 1, - // Long enough to look like the controller's hex, and obviously fake. - token: `mock-share-token-${existing.length + 1}`, - session_id: sessionId, - user_id: "alice", - read_only: readOnly, - created_at: new Date().toISOString(), - }; - writeShares([...existing, share]); - return share; -} - -export function deleteShare(token: string): void { - writeShares(readShares().filter((share) => share.token !== token)); -} - // --------------------------------------------------------------------------- // Agent templates // --------------------------------------------------------------------------- diff --git a/ui/src/mocks/transport.ts b/ui/src/mocks/transport.ts index c792efbf5..63afa712d 100644 --- a/ui/src/mocks/transport.ts +++ b/ui/src/mocks/transport.ts @@ -85,14 +85,7 @@ import { AgentTemplateService } from "@/generated/kagent/api/v1alpha1/agent_temp import { ModelService } from "@/generated/kagent/api/v1alpha1/models_pb"; import { ToolService } from "@/generated/kagent/api/v1alpha1/tools_pb"; import { PromptTemplateService } from "@/generated/kagent/api/v1alpha1/prompts_pb"; -import { - SessionService, - TaskStoreService, - type SessionSchema, - type SessionShareSchema, -} from "@/generated/kagent/api/v1alpha1/sessions_pb"; import { SystemService } from "@/generated/kagent/api/v1alpha1/system_pb"; -import { Role as PbRole, TaskState as PbTaskState } from "@/generated/a2a_pb"; import { AgentInstanceOperation as PbAgentInstanceOperation, AgentInstanceService, @@ -116,7 +109,6 @@ import type { } from "@/api/domain/agents"; import type { ModelConfig, ModelConfigSpec } from "@/api/domain/models"; import type { PromptTemplateDetail } from "@/api/domain/prompts"; -import type { Session, SessionShare } from "@/api/domain/sessions"; import { SCENARIO_DELAY_MS, currentAuthScenario, @@ -140,16 +132,12 @@ import { allModels, allPromptDetails, allPromptSummaries, - allSessions, allToolServers, buildAgentResponse, - createShare, - deleteShare, markDeleted, allHarnesses, saveHarness, promptRef, - readShares, saveAgent, saveAgentInstance, saveAgentTemplate, @@ -158,7 +146,6 @@ import { revokeInstanceShare, saveModel, savePrompt, - saveSession, saveToolServer, } from "./state"; @@ -329,21 +316,6 @@ function headerRecord(header: HeadersInit | undefined): Record { return record; } -/** - * One header, whatever case it was written in. - * - * A `Headers` object lowercases its keys and a transform's record does not, so a - * fake that indexed the record directly would miss `X-Share-Token` exactly when a - * transform had set it — which is the only time it is ever there. - */ -function headerValue(headers: Record, name: string): string | undefined { - const wanted = name.toLowerCase(); - for (const [key, value] of Object.entries(headers)) { - if (key.toLowerCase() === wanted) return value; - } - return undefined; -} - // --------------------------------------------------------------------------- // Shared shapes // --------------------------------------------------------------------------- @@ -736,185 +708,6 @@ on(PromptTemplateService.method.deletePromptTemplate, (input) => { return {}; }); -// --------------------------------------------------------------------------- -// Sessions -// --------------------------------------------------------------------------- - -function sessionMessage(session: Session): MessageInitShape { - return { - id: session.id, - name: session.name, - userId: session.user_id, - agentId: session.agent_id, - createdAt: stamp(session.created_at), - updatedAt: stamp(session.updated_at), - deletedAt: stamp(session.deleted_at), - shareToken: session.share_token ?? undefined, - shareReadOnly: session.share_read_only ?? undefined, - }; -} - -function shareMessage( - share: SessionShare, -): MessageInitShape { - return { - // `int64` in the proto, so the generated type wants a bigint. - id: BigInt(share.id), - token: share.token, - sessionId: share.session_id, - userId: share.user_id, - readOnly: share.read_only, - createdAt: stamp(share.created_at), - }; -} - -on(SessionService.method.listSessionsByAgent, (input, call) => { - // The agent a conversation belongs to is carried as `namespace__NS__name`. - const wanted = `${input.agentRef?.namespace ?? ""}__NS__${input.agentRef?.name ?? ""}`; - return { - sessions: - call.scenario === "empty" - ? [] - : allSessions() - .filter((session) => session.agent_id === wanted) - .map(sessionMessage), - }; -}); - -/** - * One conversation, and the only place a share token means anything here. - * - * A call carrying `X-Share-Token` is a visitor spending a share link, and a token - * this tab never issued — or one revoked since — is refused the way the controller - * refuses it. Without that check a client that sent no token, or the wrong one, - * would be served the conversation and look correct. - */ -on(SessionService.method.getSession, (input, call) => { - const token = headerValue(call.headers, "X-Share-Token"); - const share = token - ? readShares().find( - (candidate) => - candidate.token === token && candidate.session_id === input.sessionId, - ) - : undefined; - - if (token && !share) { - throw new ConnectError( - "Invalid or expired share token.", - Code.PermissionDenied, - ); - } - - const found = - call.scenario === "empty" - ? undefined - : allSessions().find((session) => session.id === input.sessionId); - if (!found) throw notFound(`conversation ${input.sessionId}`); - - return { - session: sessionMessage(found), - // The transcript is the chat client's, not the task store's — see - // `TaskStoreService/ListTasks` below. - events: [], - // Reported beside the session rather than on it, because it describes *this - // caller's* access: whoever opened the link, not the record. - readOnly: share?.read_only, - }; -}); - -on(SessionService.method.createSession, (input) => ({ - session: sessionMessage( - saveSession({ id: input.id, agentRef: input.agentRef, name: input.name }), - ), -})); - -on(SessionService.method.deleteSession, (input) => { - markDeleted(input.sessionId); - return {}; -}); - -on(SessionService.method.listSessionShares, (input) => ({ - shares: readShares() - .filter((share) => share.session_id === input.sessionId) - .map(shareMessage), -})); - -on(SessionService.method.createSessionShare, (input) => ({ - // The controller defaults `read_only` to true and treats false as a deliberate - // opt-in to read-write, which is the right way round for handing out access. - share: shareMessage(createShare(input.sessionId, input.readOnly ?? true)), -})); - -on(SessionService.method.deleteSessionShare, (input) => { - deleteShare(input.token); - return {}; -}); - -/** - * No tasks, deliberately. - * - * In mock mode a conversation's transcript comes from `MockChatClient`, which - * keeps its own per-session history — the task store is not where it lives. Making - * up A2A `Task` messages here would put messages on screen that the chat client - * never produced and cannot continue. - */ -/** - * The stored turns of a session, which only the shared-conversation page reads. - * - * Live chat replays itself from the A2A gateway and never comes through here — the - * gateway knows about instances, not sessions. A share token names a session, so - * this is the one path that still resolves one, and it needs something to render: - * answering with no tasks made a working share look like a share of an empty - * conversation. - * - * Built as protos rather than as A2A JSON, because that is what the wire carries and - * what the client now reads. A `Part` is a **oneof** here with no `kind` field - * anywhere — writing `{kind: "text", text}` produces a document that parses and - * yields no messages, which is the exact trap this fixture would otherwise hide. - */ -on(TaskStoreService.method.listTasks, (input) => { - if (input.sessionId !== SHARED_SESSION_ID) return { tasks: [] }; - return { - tasks: [ - { - id: "task-shared-1", - contextId: SHARED_SESSION_ID, - status: { - state: PbTaskState.COMPLETED, - // The turn's own instant. History messages carry no time of their own, so - // a page stamping them with the clock now would be wrong by however long - // ago the conversation was. - timestamp: timestampFromDate(new Date("2026-08-01T08:59:00Z")), - }, - history: [ - { - messageId: "shared-1-user", - role: PbRole.USER, - parts: [{ content: { case: "text" as const, value: "Why is checkout crashlooping?" } }], - }, - { - messageId: "shared-1-agent", - role: PbRole.AGENT, - parts: [ - { - content: { - case: "text" as const, - value: - "The **checkout** pod is failing its liveness probe. Its last restart was 4 minutes ago.", - }, - }, - ], - }, - ], - artifacts: [], - }, - ], - }; -}); - -/** The session the seeded share link points at. */ -const SHARED_SESSION_ID = "session-8f31"; - // --------------------------------------------------------------------------- // Agent instances // --------------------------------------------------------------------------- diff --git a/ui/src/pages/AgentChatPage.tsx b/ui/src/pages/AgentChatPage.tsx index bddfa151e..5a2375186 100644 --- a/ui/src/pages/AgentChatPage.tsx +++ b/ui/src/pages/AgentChatPage.tsx @@ -50,12 +50,9 @@ const LIFECYCLE_POLL_MS = 1_000; * * ## Sharing * - * A share is over the instance, because the instance is the conversation. That - * needed a server-side change to be honest and now has one: the gRPC interceptor - * used to resolve `X-Share-Token` only through `GetSessionShareByToken`, producing a - * context naming a *session*, while the A2A gateway authorises on the instance — so - * a link created here would have been refused when opened. The interceptor now tries - * both kinds of share and the gateway reads the instance as the share's owner. + * A share is over the instance, because the instance is the conversation. The gRPC + * interceptor validates its `X-Share-Token`, and the A2A gateway authorises access + * to that same instance as the share's owner. */ /** Where the agent panel's open state is remembered, per reader. */ const CONTEXT_OPEN = "kagent.chat.agentPanel.open"; @@ -695,4 +692,3 @@ export function AgentChatPage() { ); } - diff --git a/ui/src/pages/SharedAgentPage.tsx b/ui/src/pages/SharedAgentPage.tsx index 85d10db9c..47c0d9cc8 100644 --- a/ui/src/pages/SharedAgentPage.tsx +++ b/ui/src/pages/SharedAgentPage.tsx @@ -23,17 +23,9 @@ const { Text } = Typography; * what one account may read, it does not replace authentication, and an * unauthenticated request carrying a token is refused. * - * ## Why this page could not exist until now - * - * `AgentInstanceService` has carried `CreateAgentInstanceShare` all along, but - * nothing on the read path honoured what it minted: the gRPC interceptor resolved - * `X-Share-Token` through `GetSessionShareByToken` and built a share context naming - * a *session*, while the A2A gateway authorises on the instance. A link created from - * the dialog would have been refused here. - * - * The interceptor now tries both kinds of share, and the gateway reads the instance - * as the share's owner when the token names that instance — which it has to, because - * an instance is scoped to its creator and reading it as the visitor finds nothing. + * The gRPC interceptor validates the instance share token, and the A2A gateway reads + * that instance as the share's owner. That is required because instances are scoped + * to their creator; reading one as the visitor would find nothing. * * ## Replying, when the share allows it * diff --git a/ui/src/pages/SharedSessionPage.tsx b/ui/src/pages/SharedSessionPage.tsx deleted file mode 100644 index f17f1acac..000000000 --- a/ui/src/pages/SharedSessionPage.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { Alert, Skeleton, Typography } from "antd"; -import { useTheme } from "@emotion/react"; -import { useParams } from "react-router-dom"; -import { PageFrame } from "@/components/Structure/PageFrame"; -import { ChatTranscript } from "@/components/chat/ChatTranscript"; -import { useSessionTranscript } from "@/api/hooks/useSessionTranscript"; -import { useSession } from "@/api"; -import { useShareToken } from "@/api/shareToken"; - -const { Text } = Typography; - -/** `namespace__NS__name`, as the controller identifies a session's agent. */ -const AGENT_ID_SEPARATOR = "__NS__"; - -/** - * A conversation someone shared, opened by the link they sent. - * - * ## What a share is - * - * A capability. The owner creates a token from their own conversation, and whoever holds - * it can read that conversation — the backend resolves the token to the owner and answers - * as though the owner had asked, while keeping the visitor's identity for the record. The - * visitor still signs in as themselves: a share widens what one account may read, it does - * not replace authentication, and an unauthenticated request with a token is refused. - * - * ## Why this page exists - * - * The two halves of this feature had been apart for a while. Tokens could be created, - * listed and revoked from the conversation's own dialog, and the backend honoured them on - * every session route — but nothing in the app could *spend* one, so the tokens were - * strings with no way to use them and the dialog was offering a share that did not work. - * - * ## Read-only, deliberately - * - * A share can be marked writable, and the backend then allows a visitor to send into the - * conversation. This page does not offer that even for a writable share: the affordance - * would be a composer that works for some links and not others, with the difference - * invisible until it failed. What the share is is stated instead. - */ -export function SharedSessionPage() { - const theme = useTheme(); - const { sessionId, token } = useParams<{ sessionId: string; token: string }>(); - - // Before the two reads below, and that ordering is load-bearing: effects run in the - // order their hooks were called, so registering here is what puts the token on the - // first request rather than on a retry. - useShareToken(sessionId, token); - - const session = useSession(sessionId); - // Not `useChat`: that is addressed by AgentInstance and speaks to the A2A - // gateway, which knows nothing about sessions. A share token names a session, so - // this reads the session's stored turns instead. - const chat = useSessionTranscript(sessionId); - - const agentRef = session.data?.agent_id?.replace(AGENT_ID_SEPARATOR, "/"); - // The conversation's own name when it has one: a session created from a first - // message carries it, and an unnamed one should not be titled with an empty string. - const title = session.data?.name?.trim() || "Shared conversation"; - - return ( - - {/* Said on the page, not only in the URL. A reader who was sent a link has no - other way to know that what they are looking at is somebody else's - conversation, or why there is nowhere to reply. */} - - - {session.error ? ( - - ) : session.isLoading ? ( - - ) : ( -
- -
- )} - - - Shares can be revoked by whoever created them, and this link stops working when - they are. - -
- ); -} diff --git a/ui/src/router/router.tsx b/ui/src/router/router.tsx index b743dc262..c944c34a3 100644 --- a/ui/src/router/router.tsx +++ b/ui/src/router/router.tsx @@ -33,7 +33,6 @@ import { PromptNewPage } from "@/pages/PromptNewPage"; import { PromptDetailPage } from "@/pages/PromptDetailPage"; import { SubstratePage } from "@/pages/SubstratePage"; import { AppDetailPage } from "@/pages/AppDetailPage"; -import { SharedSessionPage } from "@/pages/SharedSessionPage"; import { SharedAgentPage } from "@/pages/SharedAgentPage"; import { LoginPage } from "@/pages/LoginPage"; import { NotFoundPage } from "@/pages/NotFoundPage"; @@ -83,7 +82,6 @@ const coreLayoutRoutes: (RouteObject & { key: string })[] = [ { key: "promptDetail", path: paths.promptDetail, element: }, { key: "substrate", path: paths.substrate, element: }, { key: "appDetail", path: paths.appDetail, element: }, - { key: "sharedSession", path: paths.sharedSession, element: }, { key: "sharedAgent", path: paths.sharedAgent, element: }, ]; diff --git a/ui/src/router/routes.ts b/ui/src/router/routes.ts index c4c977341..984ca6ae1 100644 --- a/ui/src/router/routes.ts +++ b/ui/src/router/routes.ts @@ -114,25 +114,12 @@ export const paths = { appDetail: "/apps/:appName", - /* - * A conversation opened through a share link. - * - * The token is in the path because that is what makes the link a link — it is the - * whole credential, and one a reader forwards by copying the address bar. Which - * conversation it is for is in the path too: the token identifies the share to the - * backend, but the app has to know what to ask for before it can spend it. - */ - sharedSession: "/shared/:sessionId/:token", - /* * A conversation opened through a share link. * * Addressed by the instance, because the instance *is* the conversation. The - * token is in the path for the reason the session one is: it is the whole - * credential, and one a reader forwards by copying the address bar. - * - * `sharedSession` above is kept for the links issued before this existed. It reads - * a session's stored turns; this one reads the instance's through the A2A gateway. + * token is in the path because it is the whole credential, and one a reader + * forwards by copying the address bar. */ sharedAgent: "/shared/agent/:namespace/:id/:token", } as const;