Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions client/components/workflow-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
21 changes: 21 additions & 0 deletions client/lib/agentMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
20 changes: 17 additions & 3 deletions client/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -412,6 +417,8 @@ async function json<T>(res: Response): Promise<T> {
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 {
Expand Down Expand Up @@ -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,
Expand All @@ -545,14 +554,19 @@ export const api = {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text,
mode,
...identity,
...(model ? { model } : {}),
...(attachments?.length ? { attachments } : {}),
...(reminder ? { reminder } : {}),
...(workflow ? { workflow } : {}),
}),
}).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),
Expand Down
75 changes: 63 additions & 12 deletions client/pages/Conversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -78,6 +78,11 @@ export function ConversationPage() {
const [activeWorkflow, setActiveWorkflow] = useState<WorkflowSummary | null>(null);
const [mode, setMode] = useState<AgentMode>("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<string | null>(null);
// null = unchecked, true/false = live roster verdict. Only true enables send.
const [foreignAgentAvailable, setForeignAgentAvailable] = useState<boolean | null>(null);
const derivedModeMessage = useRef<string | undefined>(undefined);
const modeSelectionDirty = useRef(false);
const [replyingPermission, setReplyingPermission] = useState<string | null>(null);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -436,7 +474,7 @@ export function ConversationPage() {
directory,
id,
text,
mode,
foreignReady && foreignAgent ? { agent: foreignAgent } : { mode },
modelOverride,
attachments,
selectedReminder || undefined,
Expand All @@ -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)}`);
Expand All @@ -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();
Expand Down Expand Up @@ -820,6 +859,12 @@ export function ConversationPage() {
<div className="flex min-h-10 items-center rounded-md border border-[var(--color-border-default)] bg-[var(--color-background-surface-neutral-muted)] px-3 text-sm" data-testid="opencode-managed-child-agent-fixed">
Managed Child · <span className="ml-1 font-semibold">{session.managed.requestedAgent[0].toUpperCase() + session.managed.requestedAgent.slice(1)}</span>
</div>
) : foreignAgent ? (
// Identity is preserved, never remapped: the session's own agent
// is the only prompt identity offered here (issue #52, narrowed).
<div className="flex min-h-10 items-center rounded-md border border-[var(--color-border-default)] bg-[var(--color-background-surface-neutral-muted)] px-3 text-sm" data-testid="opencode-session-agent-fixed" data-available={foreignAgentAvailable === null ? "checking" : String(foreignAgentAvailable)}>
Agent · <span className="ml-1 font-semibold">{foreignAgent}</span>
</div>
) : (
<AgentModeToggle mode={agentIdentityKnown ? mode : undefined} onChange={selectMode} disabled={!agentIdentityKnown} testId="opencode-composer-mode" />
)}
Expand All @@ -840,8 +885,14 @@ export function ConversationPage() {
>
<ChevronDown aria-hidden="true" className="h-4 w-4" />
</button>
<span className={`${!agentIdentityKnown || selectedModel && !sameModel(selectedModel, currentModel) ? "block" : "hidden"} basis-full text-[11px] text-[var(--color-text-muted)]`} data-testid="opencode-current-model">
{!agentIdentityKnown ? "Agent identity unavailable; continue in the TUI or create a web session" : "switches next message"}
<span className={`${!canPrompt || selectedModel && !sameModel(selectedModel, currentModel) ? "block" : "hidden"} basis-full text-[11px] text-[var(--color-text-muted)]`} data-testid="opencode-current-model">
{canPrompt
? "switches next message"
: foreignAgent && foreignAgentAvailable === false
? `Agent "${foreignAgent}" is not available on the connected server; the session cannot be prompted from here.`
: foreignAgent
? `Verifying agent "${foreignAgent}" against the live roster…`
: "Agent identity unavailable; continue in the TUI or create a web session"}
</span>
</div>
{attachments.length > 0 && <div className="mb-2 flex flex-wrap gap-2">{attachments.map((attachment, index) => <button key={`${attachment.filename}-${index}`} type="button" onClick={() => setAttachments((items) => items.filter((_, itemIndex) => itemIndex !== index))} className="rounded border border-[var(--color-border-default)] px-2 py-1 text-xs" data-testid="opencode-attachment-chip">{attachment.filename} x</button>)}</div>}
Expand Down Expand Up @@ -926,7 +977,7 @@ export function ConversationPage() {
/>
)}
<span className="flex-1" aria-hidden="true" />
<Button size="sm" className="min-h-11 shrink-0 sm:min-h-8" onClick={() => void send()} disabled={!agentIdentityKnown || sending || !draft.trim()} data-testid="opencode-send">
<Button size="sm" className="min-h-11 shrink-0 sm:min-h-8" onClick={() => void send()} disabled={!canPrompt || sending || !draft.trim()} data-testid="opencode-send">
{sending ? "Sending…" : "Send"}
</Button>
</div>
Expand Down
107 changes: 99 additions & 8 deletions server/opencode/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,20 +110,33 @@ export class ModePolicyActivationError extends Error {
}
}

export type SessionAgentIdentityErrorCode = "SESSION_AGENT_UNKNOWN" | "SESSION_AGENT_UNSUPPORTED";
export type SessionAgentIdentityErrorCode =
| "SESSION_AGENT_UNKNOWN"
| "SESSION_AGENT_UNSUPPORTED"
| "SESSION_AGENT_MISMATCH";

export class SessionAgentIdentityError extends Error {
constructor(
readonly code: SessionAgentIdentityErrorCode,
readonly agent?: string,
) {
super(agent
? `This session uses OpenCode agent "${agent}". The web UI can only prompt Plan or Build sessions; continue it in the TUI or create a web session.`
: "This session's OpenCode agent could not be established. Continue it in the TUI or create a web Plan or Build session.");
super(code === "SESSION_AGENT_MISMATCH"
? `This session is driven by OpenCode agent "${agent}". Prompt it with that agent; switching a session to a different agent is not supported.`
: agent
? `This session uses OpenCode agent "${agent}". Prompt it with that agent explicitly, or continue it in the TUI.`
: "This session's OpenCode agent could not be established. Continue it in the TUI or create a web Plan or Build session.");
this.name = "SessionAgentIdentityError";
}
}

/** The connected server's live roster no longer offers the requested agent. */
export class SessionAgentUnavailableError extends Error {
constructor(readonly agent: string) {
super(`OpenCode agent "${agent}" is not available on the connected server; the prompt was not sent.`);
this.name = "SessionAgentUnavailableError";
}
}

function validRuleset(value: unknown): value is PermissionRuleset {
return Array.isArray(value) && value.every((rule) => {
if (!rule || typeof rule !== "object") return false;
Expand Down Expand Up @@ -174,18 +187,26 @@ function hasPlanDenial(rules: PermissionRuleset, toolIDs: string[]): boolean {
});
}

function assertModeAgentIdentity(session: RawSession, messages: RawMessage[]): void {
// User messages persist the selected/session-driving agent. Assistant agents
// include internal execution identities such as the automatic compactor.
/**
* The agents a session's identity is composed of: the session record's agent
* plus the latest user message's agent. User messages persist the
* selected/session-driving agent; assistant agents include internal execution
* identities such as the automatic compactor and are not identity.
*/
function drivingAgents(session: RawSession, messages: RawMessage[]): string[] {
let messageAgent: string | undefined;
for (let index = messages.length - 1; index >= 0; index -= 1) {
const info = messages[index].info;
if (info?.role !== "user" || typeof info.agent !== "string" || !info.agent) continue;
messageAgent = info.agent;
break;
}
const agents = [session.agent, messageAgent]
return [session.agent, messageAgent]
.filter((agent): agent is string => typeof agent === "string" && agent.length > 0);
}

function assertModeAgentIdentity(session: RawSession, messages: RawMessage[]): void {
const agents = drivingAgents(session, messages);
const unsupported = agents.find((agent) => agent !== "plan" && agent !== "build");
if (unsupported) throw new SessionAgentIdentityError("SESSION_AGENT_UNSUPPORTED", unsupported);
if (agents.length === 0) throw new SessionAgentIdentityError("SESSION_AGENT_UNKNOWN");
Expand Down Expand Up @@ -846,6 +867,76 @@ export async function prompt(
});
}

export interface SessionAgentSummary {
id: string;
description?: string;
}

/**
* Agents a session prompt may name (issue #52, narrowed): the live roster
* minus hidden internals and delegation-only subagents. Plan and Build stay in
* the list — they remain the only agents whose prompts activate session
* policy — so the catalogue is the single source for the composer's choices.
*/
export async function listSessionAgents(
config: OpencodeConfig,
directory: string,
): Promise<SessionAgentSummary[]> {
const agents = await managedAgentCatalogue(config, directory);
return agents
.filter((agent): agent is RawAgent & { name: string } =>
typeof agent?.name === "string" && agent.name.length > 0 && agent.hidden !== true && agent.mode !== "subagent")
.map((agent) => ({
id: agent.name,
...(typeof agent.description === "string" && agent.description ? { description: agent.description } : {}),
}));
}

/**
* Prompt a session with the arbitrary agent identity it already has.
*
* The narrowed #52 contract:
* - identity is preserved, never remapped — the named agent must equal the
* session's own driving agent, so this can never switch a session's agent;
* - no Plan/Build session permission rules are applied or patched; the
* agent's own configured policy (plus any existing session ceiling) governs
* the turn;
* - the agent must still exist, visible and session-capable, on the live
* roster — a vanished agent fails loudly before anything is sent.
* Plan and Build are excluded here because their prompts must keep flowing
* through the policy-activating path.
*/
export async function promptSessionAgent(
config: OpencodeConfig,
directory: string,
sessionID: string,
input: Omit<PromptInput, "mode"> & { agent: string },
): Promise<void> {
await withSessionPromptLock(directory, sessionID, async () => {
const [session, messages, roster] = await Promise.all([
request<RawSession>(config, `/session/${encodeURIComponent(sessionID)}`, { directory }),
request<RawMessage[]>(config, `/session/${encodeURIComponent(sessionID)}/message`, {
directory,
query: { limit: 100 },
}),
listSessionAgents(config, directory),
]);
const identity = drivingAgents(session, messages ?? []);
if (identity.length === 0) throw new SessionAgentIdentityError("SESSION_AGENT_UNKNOWN");
const mismatch = identity.find((agent) => agent !== input.agent);
if (mismatch) throw new SessionAgentIdentityError("SESSION_AGENT_MISMATCH", mismatch);
if (!roster.some((agent) => agent.id === input.agent)) {
throw new SessionAgentUnavailableError(input.agent);
}
await submitPromptAsync(config, directory, sessionID, {
...input,
// `mode` is unused when `agent` is present; submit sends agent verbatim.
mode: "build",
agent: input.agent,
});
});
}

export interface ManagedChildInput {
parentID: string;
text: string;
Expand Down
Loading
Loading