From 6715bdce7e0d756ce5bc248c7c80690538eb4e20 Mon Sep 17 00:00:00 2001 From: Leon Cheng Date: Tue, 25 Aug 2026 17:13:56 -0400 Subject: [PATCH] Support data-driven session agent identity in prompts --- client/components/workflow-dialog.tsx | 4 +- client/lib/agentMode.ts | 21 +++++ client/lib/api.ts | 20 ++++- client/pages/Conversation.tsx | 75 +++++++++++++++--- server/opencode/sessions.ts | 107 ++++++++++++++++++++++++-- server/routes/sessions.ts | 36 +++++++++ tests/e2e/mock-opencode.ts | 39 ++++++++++ tests/e2e/session-agents.spec.ts | 98 +++++++++++++++++++++++ 8 files changed, 375 insertions(+), 25 deletions(-) create mode 100644 tests/e2e/session-agents.spec.ts diff --git a/client/components/workflow-dialog.tsx b/client/components/workflow-dialog.tsx index 6cd63dc0..4b19942a 100644 --- a/client/components/workflow-dialog.tsx +++ b/client/components/workflow-dialog.tsx @@ -135,14 +135,14 @@ export function WorkflowDialog({ setError(null); try { if (workflow.id === PLAYWRIGHT_REVIEW_WORKFLOW_ID) { - await api.prompt(directory, sessionID, generatedPrompt, mode, undefined, undefined, undefined, workflow.id); + await api.prompt(directory, sessionID, generatedPrompt, { mode }, undefined, undefined, undefined, workflow.id); onSent(); onClose(); return; } if (workflow.id === SESSION_UPDATE_WORKFLOW_ID) { if (!targetSession) return; - await api.prompt(directory, targetSession.id, generatedPrompt, targetMode, undefined, undefined, undefined, workflow.id); + await api.prompt(directory, targetSession.id, generatedPrompt, { mode: targetMode }, undefined, undefined, undefined, workflow.id); setStage("done"); return; } diff --git a/client/lib/agentMode.ts b/client/lib/agentMode.ts index e2dae535..41207f6c 100644 --- a/client/lib/agentMode.ts +++ b/client/lib/agentMode.ts @@ -33,6 +33,27 @@ export function modeFromMessages(messages: RawMessage[]): AgentMode | undefined return modeFromSession(undefined, messages); } +/** + * The single foreign agent identity driving a session, when there is one. + * + * Mirrors the server's identity rule: session agent and the latest user + * message agent must agree (or one be absent). Plan/Build sessions return + * undefined here — they are the mode toggle's domain — as do sessions with + * conflicting or missing identity, which stay unpromptable. + */ +export function foreignAgentFromSession( + sessionAgent: string | undefined, + messages: RawMessage[], +): string | undefined { + const messageAgent = latestUserAgent(messages); + const agents = [...new Set( + [sessionAgent, messageAgent] + .filter((agent): agent is string => typeof agent === "string" && agent.length > 0), + )]; + if (agents.length !== 1) return undefined; + return agents[0] === "plan" || agents[0] === "build" ? undefined : agents[0]; +} + export function latestModeMessageID(messages: RawMessage[]): string | undefined { return latestModeMessage(messages)?.info?.id; } diff --git a/client/lib/api.ts b/client/lib/api.ts index 9bcb287a..37b4eaa1 100644 --- a/client/lib/api.ts +++ b/client/lib/api.ts @@ -340,7 +340,12 @@ export interface SessionTurnDiff extends VcsFileDiff { * The status is attached so callers can distinguish "this session is gone" * (404, stop polling) from "the agent server is down" (502, keep retrying). */ -export type ApiErrorCode = "SESSION_AGENT_UNKNOWN" | "SESSION_AGENT_UNSUPPORTED" | "TURN_DIFF_TOO_LARGE"; +export type ApiErrorCode = + | "SESSION_AGENT_UNKNOWN" + | "SESSION_AGENT_UNSUPPORTED" + | "SESSION_AGENT_MISMATCH" + | "SESSION_AGENT_UNAVAILABLE" + | "TURN_DIFF_TOO_LARGE"; export class ApiError extends Error { constructor( @@ -412,6 +417,8 @@ async function json(res: Response): Promise { if ( body.code === "SESSION_AGENT_UNKNOWN" || body.code === "SESSION_AGENT_UNSUPPORTED" || + body.code === "SESSION_AGENT_MISMATCH" || + body.code === "SESSION_AGENT_UNAVAILABLE" || body.code === "TURN_DIFF_TOO_LARGE" ) code = body.code; } catch { @@ -534,7 +541,9 @@ export const api = { directory: string, id: string, text: string, - mode: AgentMode, + // Plan/Build activate session policy; a foreign identity rides the + // exclusive `agent` contract instead (issue #52, narrowed). + identity: { mode: AgentMode } | { agent: string }, model?: ModelSelection, attachments?: Array<{ filename: string; mime: string; url: string }>, reminder?: string, @@ -545,7 +554,7 @@ export const api = { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text, - mode, + ...identity, ...(model ? { model } : {}), ...(attachments?.length ? { attachments } : {}), ...(reminder ? { reminder } : {}), @@ -553,6 +562,11 @@ export const api = { }), }).then((r) => json<{ accepted: boolean }>(r)), + sessionAgents: (directory: string) => + fetch(scoped("/session-agents", directory)).then( + (r) => json<{ agents: Array<{ id: string; description?: string }> }>(r), + ), + abort: (directory: string, id: string) => fetch(scoped(`/sessions/${encodeURIComponent(id)}/abort`, directory), { method: "POST" }).then( (r) => json<{ aborted: boolean }>(r), diff --git a/client/pages/Conversation.tsx b/client/pages/Conversation.tsx index 30f5fb8c..3a597d75 100644 --- a/client/pages/Conversation.tsx +++ b/client/pages/Conversation.tsx @@ -18,7 +18,7 @@ import { ShareExportDialog } from "../components/share-export-dialog.js"; import { WorkflowDialog } from "../components/workflow-dialog.js"; import { WorkflowPicker } from "../components/workflow-picker.js"; import { api, ApiError, formatCost, type ReminderSummary, type SessionSummary, type WorkflowSummary } from "../lib/api.js"; -import { latestModeMessageID, modeFromSession, type AgentMode } from "../lib/agentMode.js"; +import { foreignAgentFromSession, latestModeMessageID, modeFromSession, type AgentMode } from "../lib/agentMode.js"; import { MAX_IMAGE_ATTACHMENTS, readImageAttachment, selectImageFiles, type ImageAttachment } from "../lib/attachments.js"; import { createComposerCollapseGuard } from "../lib/composerCollapse.js"; import { composerEnterAction } from "../lib/composerKeys.js"; @@ -78,6 +78,11 @@ export function ConversationPage() { const [activeWorkflow, setActiveWorkflow] = useState(null); const [mode, setMode] = useState("build"); const [agentIdentityKnown, setAgentIdentityKnown] = useState(false); + // A session driven by an arbitrary roster agent (issue #52, narrowed): + // identity is preserved and displayed, never remapped to Plan/Build. + const [foreignAgent, setForeignAgent] = useState(null); + // null = unchecked, true/false = live roster verdict. Only true enables send. + const [foreignAgentAvailable, setForeignAgentAvailable] = useState(null); const derivedModeMessage = useRef(undefined); const modeSelectionDirty = useRef(false); const [replyingPermission, setReplyingPermission] = useState(null); @@ -156,17 +161,47 @@ export function ConversationPage() { if (managedAgent) { modeSelectionDirty.current = false; setAgentIdentityKnown(true); + setForeignAgent(null); setMode(managedAgent === "plan" || managedAgent === "explore" ? "plan" : "build"); return; } const persistedMode = modeFromSession(session?.agent, stream.messages as RawMessage[]); - setAgentIdentityKnown(persistedMode !== undefined); - if (!persistedMode) return; - if (modeSelectionDirty.current && persistedMode !== mode) return; - modeSelectionDirty.current = false; - setMode(persistedMode); + if (persistedMode) { + setForeignAgent(null); + setAgentIdentityKnown(true); + if (modeSelectionDirty.current && persistedMode !== mode) return; + modeSelectionDirty.current = false; + setMode(persistedMode); + return; + } + // Not Plan/Build: a consistent foreign identity is promptable with its own + // agent once the live roster confirms the agent still exists. + const foreign = foreignAgentFromSession(session?.agent, stream.messages as RawMessage[]) ?? null; + setForeignAgent(foreign); + setAgentIdentityKnown(false); }, [mode, session?.agent, session?.managed?.requestedAgent, stream.loaded, stream.messages]); + useEffect(() => { + if (!foreignAgent || !directory) { + setForeignAgentAvailable(null); + return; + } + let cancelled = false; + setForeignAgentAvailable(null); + void api.sessionAgents(directory) + .then((result) => { + if (cancelled) return; + setForeignAgentAvailable(result.agents.some((agent) => agent.id === foreignAgent)); + }) + .catch(() => { + // Roster unavailable = agent unverifiable = keep the composer closed. + if (!cancelled) setForeignAgentAvailable(false); + }); + return () => { + cancelled = true; + }; + }, [directory, foreignAgent]); + const selectMode = (nextMode: AgentMode) => { modeSelectionDirty.current = true; setMode(nextMode); @@ -425,6 +460,9 @@ export function ConversationPage() { textarea.style.height = `${textarea.scrollHeight}px`; }, [draft]); + const foreignReady = foreignAgent !== null && foreignAgentAvailable === true; + const canPrompt = agentIdentityKnown || foreignReady; + const send = async () => { const text = draft.trim(); if (!text) return; @@ -436,7 +474,7 @@ export function ConversationPage() { directory, id, text, - mode, + foreignReady && foreignAgent ? { agent: foreignAgent } : { mode }, modelOverride, attachments, selectedReminder || undefined, @@ -455,7 +493,8 @@ export function ConversationPage() { stream.refresh(); } catch (error) { if (error instanceof ApiError && - (error.code === "SESSION_AGENT_UNKNOWN" || error.code === "SESSION_AGENT_UNSUPPORTED")) { + (error.code === "SESSION_AGENT_UNKNOWN" || error.code === "SESSION_AGENT_UNSUPPORTED" || + error.code === "SESSION_AGENT_MISMATCH" || error.code === "SESSION_AGENT_UNAVAILABLE")) { setComposerError(error.message); } else { setComposerError(`Could not send the prompt: ${error instanceof Error ? error.message : String(error)}`); @@ -481,7 +520,7 @@ export function ConversationPage() { coarsePointer: window.matchMedia("(pointer: coarse)").matches, // `send()` has no re-entry guard and prompt_async returns as soon as // the turn is queued, so a fast double Enter would post two turns. - canSubmit: agentIdentityKnown && !sending && draft.trim().length > 0, + canSubmit: canPrompt && !sending && draft.trim().length > 0, }, ); if (action.preventDefault) event.preventDefault(); @@ -820,6 +859,12 @@ export function ConversationPage() {
Managed Child · {session.managed.requestedAgent[0].toUpperCase() + session.managed.requestedAgent.slice(1)}
+ ) : foreignAgent ? ( + // Identity is preserved, never remapped: the session's own agent + // is the only prompt identity offered here (issue #52, narrowed). +
+ Agent · {foreignAgent} +
) : ( )} @@ -840,8 +885,14 @@ export function ConversationPage() { >