mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-20 00:02:19 +00:00
Merge origin/main into share-chat-ui-components
This commit is contained in:
commit
b609f1ab2b
306 changed files with 44551 additions and 1001 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "acp-http-client",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "Protocol-faithful ACP JSON-RPC over streamable HTTP client.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -378,31 +378,39 @@ class StreamableHttpAcpTransport {
|
|||
});
|
||||
|
||||
const url = this.buildUrl(this.bootstrapQueryIfNeeded());
|
||||
const response = await this.fetcher(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(message),
|
||||
});
|
||||
|
||||
this.postedOnce = true;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AcpHttpError(response.status, await readProblem(response), response);
|
||||
}
|
||||
|
||||
this.ensureSseLoop();
|
||||
void this.postMessage(url, headers, message);
|
||||
}
|
||||
|
||||
if (response.status === 200) {
|
||||
const text = await response.text();
|
||||
if (text.trim()) {
|
||||
const envelope = JSON.parse(text) as AnyMessage;
|
||||
this.pushInbound(envelope);
|
||||
private async postMessage(url: string, headers: Headers, message: AnyMessage): Promise<void> {
|
||||
try {
|
||||
const response = await this.fetcher(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(message),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AcpHttpError(response.status, await readProblem(response), response);
|
||||
}
|
||||
} else {
|
||||
|
||||
if (response.status === 200) {
|
||||
const text = await response.text();
|
||||
if (text.trim()) {
|
||||
const envelope = JSON.parse(text) as AnyMessage;
|
||||
this.pushInbound(envelope);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Drain response body so the underlying connection is released back to
|
||||
// the pool. Without this, Node.js undici keeps the socket occupied and
|
||||
// the pool. Without this, Node.js undici keeps the socket occupied and
|
||||
// may stall subsequent requests to the same origin.
|
||||
await response.text().catch(() => {});
|
||||
} catch (error) {
|
||||
console.error("ACP write error:", error);
|
||||
this.failReadable(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -140,4 +140,54 @@ describe("AcpHttpClient integration", () => {
|
|||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
it("answers session/request_permission while session/prompt is still in flight", async () => {
|
||||
const permissionRequests: Array<{ sessionId: string; title?: string | null }> = [];
|
||||
const serverId = `acp-http-client-permissions-${Date.now().toString(36)}`;
|
||||
|
||||
const client = new AcpHttpClient({
|
||||
baseUrl,
|
||||
token,
|
||||
transport: {
|
||||
path: `/v1/acp/${encodeURIComponent(serverId)}`,
|
||||
bootstrapQuery: { agent: "mock" },
|
||||
},
|
||||
client: {
|
||||
requestPermission: async (request) => {
|
||||
permissionRequests.push({
|
||||
sessionId: request.sessionId,
|
||||
title: request.toolCall.title,
|
||||
});
|
||||
return {
|
||||
outcome: {
|
||||
outcome: "selected",
|
||||
optionId: "reject-once",
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await client.initialize();
|
||||
|
||||
const session = await client.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
const prompt = await client.prompt({
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "please trigger permission" }],
|
||||
});
|
||||
|
||||
expect(prompt.stopReason).toBe("end_turn");
|
||||
expect(permissionRequests).toEqual([
|
||||
{
|
||||
sessionId: session.sessionId,
|
||||
title: "Write mock.txt",
|
||||
},
|
||||
]);
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/cli-shared",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "Shared helpers for sandbox-agent CLI and SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/cli",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "CLI for sandbox-agent - run AI coding agents in sandboxes",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/cli-darwin-arm64",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "sandbox-agent CLI binary for macOS ARM64",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/cli-darwin-x64",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "sandbox-agent CLI binary for macOS x64",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/cli-linux-arm64",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "sandbox-agent CLI binary for Linux arm64",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/cli-linux-x64",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "sandbox-agent CLI binary for Linux x64",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/cli-win32-x64",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "sandbox-agent CLI binary for Windows x64",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/gigacode",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "Gigacode CLI (sandbox-agent with OpenCode attach by default)",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/gigacode-darwin-arm64",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "gigacode CLI binary for macOS arm64",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/gigacode-darwin-x64",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "gigacode CLI binary for macOS x64",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/gigacode-linux-arm64",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "gigacode CLI binary for Linux arm64",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/gigacode-linux-x64",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "gigacode CLI binary for Linux x64",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/gigacode-win32-x64",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "gigacode CLI binary for Windows x64",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/persist-indexeddb",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "IndexedDB persistence driver for the Sandbox Agent TypeScript SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/persist-postgres",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "PostgreSQL persistence driver for the Sandbox Agent TypeScript SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/persist-rivet",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "Rivet Actor persistence driver for the Sandbox Agent TypeScript SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/persist-sqlite",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "SQLite persistence driver for the Sandbox Agent TypeScript SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@sandbox-agent/react",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "React components for Sandbox Agent frontend integrations",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -3,10 +3,18 @@
|
|||
import type { ReactNode, RefObject } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export type PermissionReply = "once" | "always" | "reject";
|
||||
|
||||
export type PermissionOption = {
|
||||
optionId: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
};
|
||||
|
||||
export type TranscriptEntry = {
|
||||
id: string;
|
||||
eventId?: string;
|
||||
kind: "message" | "tool" | "meta" | "reasoning";
|
||||
kind: "message" | "tool" | "meta" | "reasoning" | "permission";
|
||||
time: string;
|
||||
role?: "user" | "assistant";
|
||||
text?: string;
|
||||
|
|
@ -16,6 +24,14 @@ export type TranscriptEntry = {
|
|||
toolStatus?: string;
|
||||
reasoning?: { text: string; visibility?: string };
|
||||
meta?: { title: string; detail?: string; severity?: "info" | "error" };
|
||||
permission?: {
|
||||
permissionId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
options: PermissionOption[];
|
||||
resolved?: boolean;
|
||||
selectedOptionId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export interface AgentTranscriptClassNames {
|
||||
|
|
@ -50,6 +66,14 @@ export interface AgentTranscriptClassNames {
|
|||
toolSectionTitle: string;
|
||||
toolCode: string;
|
||||
toolCodeMuted: string;
|
||||
permissionPrompt: string;
|
||||
permissionHeader: string;
|
||||
permissionIcon: string;
|
||||
permissionTitle: string;
|
||||
permissionDescription: string;
|
||||
permissionActions: string;
|
||||
permissionButton: string;
|
||||
permissionAutoResolved: string;
|
||||
thinkingRow: string;
|
||||
thinkingAvatar: string;
|
||||
thinkingAvatarImage: string;
|
||||
|
|
@ -59,6 +83,16 @@ export interface AgentTranscriptClassNames {
|
|||
endAnchor: string;
|
||||
}
|
||||
|
||||
export interface PermissionOptionRenderContext {
|
||||
entry: TranscriptEntry;
|
||||
option: PermissionOption;
|
||||
label: string;
|
||||
reply: PermissionReply;
|
||||
selected: boolean;
|
||||
dimmed: boolean;
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
export interface AgentTranscriptProps {
|
||||
entries: TranscriptEntry[];
|
||||
className?: string;
|
||||
|
|
@ -69,6 +103,7 @@ export interface AgentTranscriptProps {
|
|||
isThinking?: boolean;
|
||||
agentId?: string;
|
||||
onEventClick?: (eventId: string) => void;
|
||||
onPermissionReply?: (permissionId: string, reply: PermissionReply) => void;
|
||||
isDividerEntry?: (entry: TranscriptEntry) => boolean;
|
||||
canOpenEvent?: (entry: TranscriptEntry) => boolean;
|
||||
getToolGroupSummary?: (entries: TranscriptEntry[]) => string;
|
||||
|
|
@ -79,12 +114,15 @@ export interface AgentTranscriptProps {
|
|||
renderToolGroupIcon?: (entries: TranscriptEntry[], expanded: boolean) => ReactNode;
|
||||
renderChevron?: (expanded: boolean) => ReactNode;
|
||||
renderEventLinkContent?: (entry: TranscriptEntry) => ReactNode;
|
||||
renderPermissionIcon?: (entry: TranscriptEntry) => ReactNode;
|
||||
renderPermissionOptionContent?: (context: PermissionOptionRenderContext) => ReactNode;
|
||||
}
|
||||
|
||||
type GroupedEntries =
|
||||
| { type: "message"; entries: TranscriptEntry[] }
|
||||
| { type: "tool-group"; entries: TranscriptEntry[] }
|
||||
| { type: "divider"; entries: TranscriptEntry[] };
|
||||
| { type: "divider"; entries: TranscriptEntry[] }
|
||||
| { type: "permission"; entries: TranscriptEntry[] };
|
||||
|
||||
const DEFAULT_CLASS_NAMES: AgentTranscriptClassNames = {
|
||||
root: "sa-agent-transcript",
|
||||
|
|
@ -118,6 +156,14 @@ const DEFAULT_CLASS_NAMES: AgentTranscriptClassNames = {
|
|||
toolSectionTitle: "sa-agent-transcript-tool-section-title",
|
||||
toolCode: "sa-agent-transcript-tool-code",
|
||||
toolCodeMuted: "sa-agent-transcript-tool-code-muted",
|
||||
permissionPrompt: "sa-agent-transcript-permission-prompt",
|
||||
permissionHeader: "sa-agent-transcript-permission-header",
|
||||
permissionIcon: "sa-agent-transcript-permission-icon",
|
||||
permissionTitle: "sa-agent-transcript-permission-title",
|
||||
permissionDescription: "sa-agent-transcript-permission-description",
|
||||
permissionActions: "sa-agent-transcript-permission-actions",
|
||||
permissionButton: "sa-agent-transcript-permission-button",
|
||||
permissionAutoResolved: "sa-agent-transcript-permission-auto-resolved",
|
||||
thinkingRow: "sa-agent-transcript-thinking-row",
|
||||
thinkingAvatar: "sa-agent-transcript-thinking-avatar",
|
||||
thinkingAvatarImage: "sa-agent-transcript-thinking-avatar-image",
|
||||
|
|
@ -129,6 +175,8 @@ const DEFAULT_CLASS_NAMES: AgentTranscriptClassNames = {
|
|||
|
||||
const DEFAULT_DIVIDER_TITLES = new Set(["Session Started", "Turn Started", "Turn Ended"]);
|
||||
|
||||
const cx = (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(" ");
|
||||
|
||||
const mergeClassNames = (
|
||||
defaults: AgentTranscriptClassNames,
|
||||
overrides?: Partial<AgentTranscriptClassNames>,
|
||||
|
|
@ -164,6 +212,14 @@ const mergeClassNames = (
|
|||
toolSectionTitle: cx(defaults.toolSectionTitle, overrides?.toolSectionTitle),
|
||||
toolCode: cx(defaults.toolCode, overrides?.toolCode),
|
||||
toolCodeMuted: cx(defaults.toolCodeMuted, overrides?.toolCodeMuted),
|
||||
permissionPrompt: cx(defaults.permissionPrompt, overrides?.permissionPrompt),
|
||||
permissionHeader: cx(defaults.permissionHeader, overrides?.permissionHeader),
|
||||
permissionIcon: cx(defaults.permissionIcon, overrides?.permissionIcon),
|
||||
permissionTitle: cx(defaults.permissionTitle, overrides?.permissionTitle),
|
||||
permissionDescription: cx(defaults.permissionDescription, overrides?.permissionDescription),
|
||||
permissionActions: cx(defaults.permissionActions, overrides?.permissionActions),
|
||||
permissionButton: cx(defaults.permissionButton, overrides?.permissionButton),
|
||||
permissionAutoResolved: cx(defaults.permissionAutoResolved, overrides?.permissionAutoResolved),
|
||||
thinkingRow: cx(defaults.thinkingRow, overrides?.thinkingRow),
|
||||
thinkingAvatar: cx(defaults.thinkingAvatar, overrides?.thinkingAvatar),
|
||||
thinkingAvatarImage: cx(defaults.thinkingAvatarImage, overrides?.thinkingAvatarImage),
|
||||
|
|
@ -173,12 +229,11 @@ const mergeClassNames = (
|
|||
endAnchor: cx(defaults.endAnchor, overrides?.endAnchor),
|
||||
});
|
||||
|
||||
const cx = (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(" ");
|
||||
|
||||
const getMessageVariant = (entry: TranscriptEntry) => {
|
||||
if (entry.kind === "tool") return "tool";
|
||||
if (entry.kind === "meta") return entry.meta?.severity === "error" ? "error" : "system";
|
||||
if (entry.kind === "reasoning") return "assistant";
|
||||
if (entry.kind === "permission") return "system";
|
||||
if (entry.role === "user") return "user";
|
||||
return "assistant";
|
||||
};
|
||||
|
|
@ -210,10 +265,31 @@ const getDefaultToolGroupSummary = (entries: TranscriptEntry[]) => {
|
|||
return `${count} Event${count === 1 ? "" : "s"}`;
|
||||
};
|
||||
|
||||
const getPermissionReplyForOption = (kind: string): PermissionReply => {
|
||||
if (kind === "allow_once") return "once";
|
||||
if (kind === "allow_always") return "always";
|
||||
return "reject";
|
||||
};
|
||||
|
||||
const getPermissionOptionLabel = (option: PermissionOption) => {
|
||||
if (option.name) return option.name;
|
||||
if (option.kind === "allow_once") return "Allow Once";
|
||||
if (option.kind === "allow_always") return "Always Allow";
|
||||
if (option.kind === "reject_once") return "Reject";
|
||||
if (option.kind === "reject_always") return "Reject Always";
|
||||
return option.kind;
|
||||
};
|
||||
|
||||
const getPermissionOptionTone = (kind: string) => (kind.startsWith("allow") ? "allow" : "reject");
|
||||
|
||||
const defaultRenderMessageText = (entry: TranscriptEntry) => entry.text;
|
||||
const defaultRenderPendingIndicator = () => "...";
|
||||
const defaultRenderChevron = (expanded: boolean) => (expanded ? "▾" : "▸");
|
||||
const defaultRenderEventLinkContent = () => "Open";
|
||||
const defaultRenderPermissionIcon = () => "Permission";
|
||||
const defaultRenderPermissionOptionContent = ({
|
||||
label,
|
||||
}: PermissionOptionRenderContext) => label;
|
||||
const defaultIsDividerEntry = (entry: TranscriptEntry) =>
|
||||
entry.kind === "meta" && DEFAULT_DIVIDER_TITLES.has(entry.meta?.title ?? "");
|
||||
|
||||
|
|
@ -241,6 +317,12 @@ const buildGroupedEntries = (
|
|||
continue;
|
||||
}
|
||||
|
||||
if (entry.kind === "permission") {
|
||||
flushToolGroup();
|
||||
groupedEntries.push({ type: "permission", entries: [entry] });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.kind === "tool" || entry.kind === "reasoning" || entry.kind === "meta") {
|
||||
currentToolGroup.push(entry);
|
||||
continue;
|
||||
|
|
@ -486,6 +568,89 @@ const ToolGroup = ({
|
|||
);
|
||||
};
|
||||
|
||||
const PermissionPrompt = ({
|
||||
entry,
|
||||
classNames,
|
||||
onPermissionReply,
|
||||
renderPermissionIcon,
|
||||
renderPermissionOptionContent,
|
||||
}: {
|
||||
entry: TranscriptEntry;
|
||||
classNames: AgentTranscriptClassNames;
|
||||
onPermissionReply?: (permissionId: string, reply: PermissionReply) => void;
|
||||
renderPermissionIcon: (entry: TranscriptEntry) => ReactNode;
|
||||
renderPermissionOptionContent: (context: PermissionOptionRenderContext) => ReactNode;
|
||||
}) => {
|
||||
const permission = entry.permission;
|
||||
if (!permission) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolved = Boolean(permission.resolved);
|
||||
const selectedOptionId = permission.selectedOptionId;
|
||||
const canReply = Boolean(onPermissionReply) && !resolved;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(classNames.permissionPrompt, resolved && "resolved")}
|
||||
data-slot="permission-prompt"
|
||||
data-resolved={resolved ? "true" : undefined}
|
||||
>
|
||||
<div className={classNames.permissionHeader} data-slot="permission-header">
|
||||
<span className={classNames.permissionIcon} data-slot="permission-icon">
|
||||
{renderPermissionIcon(entry)}
|
||||
</span>
|
||||
<span className={classNames.permissionTitle} data-slot="permission-title">
|
||||
{permission.title}
|
||||
</span>
|
||||
</div>
|
||||
{permission.description ? (
|
||||
<div className={classNames.permissionDescription} data-slot="permission-description">
|
||||
{permission.description}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={classNames.permissionActions} data-slot="permission-actions">
|
||||
{permission.options.map((option) => {
|
||||
const reply = getPermissionReplyForOption(option.kind);
|
||||
const label = getPermissionOptionLabel(option);
|
||||
const selected = resolved && selectedOptionId === option.optionId;
|
||||
const dimmed = resolved && !selected && selectedOptionId != null;
|
||||
const tone = getPermissionOptionTone(option.kind);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.optionId}
|
||||
type="button"
|
||||
className={cx(classNames.permissionButton, tone, selected && "selected", dimmed && "dimmed")}
|
||||
data-slot="permission-button"
|
||||
data-tone={tone}
|
||||
data-selected={selected ? "true" : undefined}
|
||||
data-dimmed={dimmed ? "true" : undefined}
|
||||
disabled={!canReply}
|
||||
onClick={() => onPermissionReply?.(permission.permissionId, reply)}
|
||||
>
|
||||
{renderPermissionOptionContent({
|
||||
entry,
|
||||
option,
|
||||
label,
|
||||
reply,
|
||||
selected,
|
||||
dimmed,
|
||||
resolved,
|
||||
})}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{resolved && !selectedOptionId ? (
|
||||
<span className={classNames.permissionAutoResolved} data-slot="permission-auto-resolved">
|
||||
Auto-resolved
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AgentTranscript = ({
|
||||
entries,
|
||||
className,
|
||||
|
|
@ -496,6 +661,7 @@ export const AgentTranscript = ({
|
|||
isThinking,
|
||||
agentId,
|
||||
onEventClick,
|
||||
onPermissionReply,
|
||||
isDividerEntry = defaultIsDividerEntry,
|
||||
canOpenEvent = defaultCanOpenEvent,
|
||||
getToolGroupSummary = getDefaultToolGroupSummary,
|
||||
|
|
@ -506,6 +672,8 @@ export const AgentTranscript = ({
|
|||
renderToolGroupIcon = () => null,
|
||||
renderChevron = defaultRenderChevron,
|
||||
renderEventLinkContent = defaultRenderEventLinkContent,
|
||||
renderPermissionIcon = defaultRenderPermissionIcon,
|
||||
renderPermissionOptionContent = defaultRenderPermissionOptionContent,
|
||||
}: AgentTranscriptProps) => {
|
||||
const resolvedClassNames = useMemo(
|
||||
() => mergeClassNames(DEFAULT_CLASS_NAMES, classNameOverrides),
|
||||
|
|
@ -551,6 +719,20 @@ export const AgentTranscript = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (group.type === "permission") {
|
||||
const entry = group.entries[0];
|
||||
return (
|
||||
<PermissionPrompt
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
classNames={resolvedClassNames}
|
||||
onPermissionReply={onPermissionReply}
|
||||
renderPermissionIcon={renderPermissionIcon}
|
||||
renderPermissionOptionContent={renderPermissionOptionContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const entry = group.entries[0];
|
||||
const messageVariant = getMessageVariant(entry);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ export type {
|
|||
export type {
|
||||
AgentTranscriptClassNames,
|
||||
AgentTranscriptProps,
|
||||
PermissionOption,
|
||||
PermissionOptionRenderContext,
|
||||
PermissionReply,
|
||||
TranscriptEntry,
|
||||
} from "./AgentTranscript.tsx";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "sandbox-agent",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, and Amp.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -8,8 +8,12 @@ import {
|
|||
type CancelNotification,
|
||||
type NewSessionRequest,
|
||||
type NewSessionResponse,
|
||||
type PermissionOption,
|
||||
type PermissionOptionKind,
|
||||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionConfigOption,
|
||||
type SessionNotification,
|
||||
type SessionModeState,
|
||||
|
|
@ -142,9 +146,28 @@ export interface SessionSendOptions {
|
|||
}
|
||||
|
||||
export type SessionEventListener = (event: SessionEvent) => void;
|
||||
export type PermissionReply = "once" | "always" | "reject";
|
||||
export type PermissionRequestListener = (request: SessionPermissionRequest) => void;
|
||||
export type ProcessLogListener = (entry: ProcessLogEntry) => void;
|
||||
export type ProcessLogFollowQuery = Omit<ProcessLogsQuery, "follow">;
|
||||
|
||||
export interface SessionPermissionRequestOption {
|
||||
optionId: string;
|
||||
name: string;
|
||||
kind: PermissionOptionKind;
|
||||
}
|
||||
|
||||
export interface SessionPermissionRequest {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
sessionId: string;
|
||||
agentSessionId: string;
|
||||
availableReplies: PermissionReply[];
|
||||
options: SessionPermissionRequestOption[];
|
||||
toolCall: RequestPermissionRequest["toolCall"];
|
||||
rawRequest: RequestPermissionRequest;
|
||||
}
|
||||
|
||||
export interface AgentQueryOptions {
|
||||
config?: boolean;
|
||||
noCache?: boolean;
|
||||
|
|
@ -238,6 +261,22 @@ export class UnsupportedSessionConfigOptionError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
export class UnsupportedPermissionReplyError extends Error {
|
||||
readonly permissionId: string;
|
||||
readonly requestedReply: PermissionReply;
|
||||
readonly availableReplies: PermissionReply[];
|
||||
|
||||
constructor(permissionId: string, requestedReply: PermissionReply, availableReplies: PermissionReply[]) {
|
||||
super(
|
||||
`Permission '${permissionId}' does not support reply '${requestedReply}'. Available replies: ${availableReplies.join(", ") || "(none)"}`,
|
||||
);
|
||||
this.name = "UnsupportedPermissionReplyError";
|
||||
this.permissionId = permissionId;
|
||||
this.requestedReply = requestedReply;
|
||||
this.availableReplies = availableReplies;
|
||||
}
|
||||
}
|
||||
|
||||
export class Session {
|
||||
private record: SessionRecord;
|
||||
private readonly sandbox: SandboxAgent;
|
||||
|
|
@ -280,14 +319,14 @@ export class Session {
|
|||
return this;
|
||||
}
|
||||
|
||||
async send(method: string, params: Record<string, unknown> = {}, options: SessionSendOptions = {}): Promise<unknown> {
|
||||
const updated = await this.sandbox.sendSessionMethod(this.id, method, params, options);
|
||||
async rawSend(method: string, params: Record<string, unknown> = {}, options: SessionSendOptions = {}): Promise<unknown> {
|
||||
const updated = await this.sandbox.rawSendSessionMethod(this.id, method, params, options);
|
||||
this.apply(updated.session.toRecord());
|
||||
return updated.response;
|
||||
}
|
||||
|
||||
async prompt(prompt: PromptRequest["prompt"]): Promise<PromptResponse> {
|
||||
const response = await this.send("session/prompt", { prompt });
|
||||
const response = await this.rawSend("session/prompt", { prompt });
|
||||
return response as PromptResponse;
|
||||
}
|
||||
|
||||
|
|
@ -327,6 +366,18 @@ export class Session {
|
|||
return this.sandbox.onSessionEvent(this.id, listener);
|
||||
}
|
||||
|
||||
onPermissionRequest(listener: PermissionRequestListener): () => void {
|
||||
return this.sandbox.onPermissionRequest(this.id, listener);
|
||||
}
|
||||
|
||||
async respondPermission(permissionId: string, reply: PermissionReply): Promise<void> {
|
||||
await this.sandbox.respondPermission(permissionId, reply);
|
||||
}
|
||||
|
||||
async rawRespondPermission(permissionId: string, response: RequestPermissionResponse): Promise<void> {
|
||||
await this.sandbox.rawRespondPermission(permissionId, response);
|
||||
}
|
||||
|
||||
toRecord(): SessionRecord {
|
||||
return { ...this.record };
|
||||
}
|
||||
|
|
@ -355,6 +406,12 @@ export class LiveAcpConnection {
|
|||
direction: AcpEnvelopeDirection,
|
||||
localSessionId: string | null,
|
||||
) => void;
|
||||
private readonly onPermissionRequest: (
|
||||
connection: LiveAcpConnection,
|
||||
localSessionId: string,
|
||||
agentSessionId: string,
|
||||
request: RequestPermissionRequest,
|
||||
) => Promise<RequestPermissionResponse>;
|
||||
|
||||
private constructor(
|
||||
agent: string,
|
||||
|
|
@ -366,11 +423,18 @@ export class LiveAcpConnection {
|
|||
direction: AcpEnvelopeDirection,
|
||||
localSessionId: string | null,
|
||||
) => void,
|
||||
onPermissionRequest: (
|
||||
connection: LiveAcpConnection,
|
||||
localSessionId: string,
|
||||
agentSessionId: string,
|
||||
request: RequestPermissionRequest,
|
||||
) => Promise<RequestPermissionResponse>,
|
||||
) {
|
||||
this.agent = agent;
|
||||
this.connectionId = connectionId;
|
||||
this.acp = acp;
|
||||
this.onObservedEnvelope = onObservedEnvelope;
|
||||
this.onPermissionRequest = onPermissionRequest;
|
||||
}
|
||||
|
||||
static async create(options: {
|
||||
|
|
@ -386,6 +450,12 @@ export class LiveAcpConnection {
|
|||
direction: AcpEnvelopeDirection,
|
||||
localSessionId: string | null,
|
||||
) => void;
|
||||
onPermissionRequest: (
|
||||
connection: LiveAcpConnection,
|
||||
localSessionId: string,
|
||||
agentSessionId: string,
|
||||
request: RequestPermissionRequest,
|
||||
) => Promise<RequestPermissionResponse>;
|
||||
}): Promise<LiveAcpConnection> {
|
||||
const connectionId = randomId();
|
||||
|
||||
|
|
@ -400,6 +470,12 @@ export class LiveAcpConnection {
|
|||
bootstrapQuery: { agent: options.agent },
|
||||
},
|
||||
client: {
|
||||
requestPermission: async (request: RequestPermissionRequest) => {
|
||||
if (!live) {
|
||||
return cancelledPermissionResponse();
|
||||
}
|
||||
return live.handlePermissionRequest(request);
|
||||
},
|
||||
sessionUpdate: async (_notification: SessionNotification) => {
|
||||
// Session updates are observed via envelope persistence.
|
||||
},
|
||||
|
|
@ -416,7 +492,13 @@ export class LiveAcpConnection {
|
|||
},
|
||||
});
|
||||
|
||||
live = new LiveAcpConnection(options.agent, connectionId, acp, options.onObservedEnvelope);
|
||||
live = new LiveAcpConnection(
|
||||
options.agent,
|
||||
connectionId,
|
||||
acp,
|
||||
options.onObservedEnvelope,
|
||||
options.onPermissionRequest,
|
||||
);
|
||||
|
||||
const initResult = await acp.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
|
|
@ -550,6 +632,23 @@ export class LiveAcpConnection {
|
|||
this.lastAdapterExitAt = Date.now();
|
||||
}
|
||||
|
||||
private async handlePermissionRequest(
|
||||
request: RequestPermissionRequest,
|
||||
): Promise<RequestPermissionResponse> {
|
||||
const agentSessionId = request.sessionId;
|
||||
const localSessionId = this.localByAgentSessionId.get(agentSessionId);
|
||||
if (!localSessionId) {
|
||||
return cancelledPermissionResponse();
|
||||
}
|
||||
|
||||
return this.onPermissionRequest(
|
||||
this,
|
||||
localSessionId,
|
||||
agentSessionId,
|
||||
clonePermissionRequest(request),
|
||||
);
|
||||
}
|
||||
|
||||
private resolveSessionId(envelope: AnyMessage, direction: AcpEnvelopeDirection): string | null {
|
||||
const id = envelopeId(envelope);
|
||||
const method = envelopeMethod(envelope);
|
||||
|
|
@ -782,6 +881,8 @@ export class SandboxAgent {
|
|||
private readonly pendingLiveConnections = new Map<string, Promise<LiveAcpConnection>>();
|
||||
private readonly sessionHandles = new Map<string, Session>();
|
||||
private readonly eventListeners = new Map<string, Set<SessionEventListener>>();
|
||||
private readonly permissionListeners = new Map<string, Set<PermissionRequestListener>>();
|
||||
private readonly pendingPermissionRequests = new Map<string, PendingPermissionRequestState>();
|
||||
private readonly nextSessionEventIndexBySession = new Map<string, number>();
|
||||
private readonly seedSessionEventIndexBySession = new Map<string, Promise<void>>();
|
||||
|
||||
|
|
@ -840,6 +941,11 @@ export class SandboxAgent {
|
|||
this.disposed = true;
|
||||
this.healthWaitAbortController.abort(createAbortError("SandboxAgent was disposed."));
|
||||
|
||||
for (const [permissionId, pending] of this.pendingPermissionRequests) {
|
||||
this.pendingPermissionRequests.delete(permissionId);
|
||||
pending.resolve(cancelledPermissionResponse());
|
||||
}
|
||||
|
||||
const connections = [...this.liveConnections.values()];
|
||||
this.liveConnections.clear();
|
||||
const pending = [...this.pendingLiveConnections.values()];
|
||||
|
|
@ -984,6 +1090,8 @@ export class SandboxAgent {
|
|||
}
|
||||
|
||||
async destroySession(id: string): Promise<Session> {
|
||||
this.cancelPendingPermissionsForSession(id);
|
||||
|
||||
try {
|
||||
await this.sendSessionMethodInternal(id, SESSION_CANCEL_METHOD, {}, {}, true);
|
||||
} catch {
|
||||
|
|
@ -1100,7 +1208,26 @@ export class SandboxAgent {
|
|||
|
||||
async getSessionModes(sessionId: string): Promise<SessionModeState | null> {
|
||||
const record = await this.requireSessionRecord(sessionId);
|
||||
return cloneModes(record.modes);
|
||||
if (record.modes && record.modes.availableModes.length > 0) {
|
||||
return cloneModes(record.modes);
|
||||
}
|
||||
|
||||
const hydrated = await this.hydrateSessionConfigOptions(record.id, record);
|
||||
if (hydrated.modes && hydrated.modes.availableModes.length > 0) {
|
||||
return cloneModes(hydrated.modes);
|
||||
}
|
||||
|
||||
const derived = deriveModesFromConfigOptions(hydrated.configOptions);
|
||||
if (!derived) {
|
||||
return cloneModes(hydrated.modes);
|
||||
}
|
||||
|
||||
const updated: SessionRecord = {
|
||||
...hydrated,
|
||||
modes: derived,
|
||||
};
|
||||
await this.persist.updateSession(updated);
|
||||
return cloneModes(derived);
|
||||
}
|
||||
|
||||
private async setSessionCategoryValue(
|
||||
|
|
@ -1155,7 +1282,7 @@ export class SandboxAgent {
|
|||
return updated;
|
||||
}
|
||||
|
||||
async sendSessionMethod(
|
||||
async rawSendSessionMethod(
|
||||
sessionId: string,
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
|
|
@ -1290,6 +1417,47 @@ export class SandboxAgent {
|
|||
};
|
||||
}
|
||||
|
||||
onPermissionRequest(sessionId: string, listener: PermissionRequestListener): () => void {
|
||||
const listeners = this.permissionListeners.get(sessionId) ?? new Set<PermissionRequestListener>();
|
||||
listeners.add(listener);
|
||||
this.permissionListeners.set(sessionId, listeners);
|
||||
|
||||
return () => {
|
||||
const set = this.permissionListeners.get(sessionId);
|
||||
if (!set) {
|
||||
return;
|
||||
}
|
||||
set.delete(listener);
|
||||
if (set.size === 0) {
|
||||
this.permissionListeners.delete(sessionId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async respondPermission(permissionId: string, reply: PermissionReply): Promise<void> {
|
||||
const pending = this.pendingPermissionRequests.get(permissionId);
|
||||
if (!pending) {
|
||||
throw new Error(`permission '${permissionId}' not found`);
|
||||
}
|
||||
|
||||
let response: RequestPermissionResponse;
|
||||
try {
|
||||
response = permissionReplyToResponse(permissionId, pending.request, reply);
|
||||
} catch (error) {
|
||||
pending.reject(error instanceof Error ? error : new Error(String(error)));
|
||||
this.pendingPermissionRequests.delete(permissionId);
|
||||
throw error;
|
||||
}
|
||||
this.resolvePendingPermission(permissionId, response);
|
||||
}
|
||||
|
||||
async rawRespondPermission(permissionId: string, response: RequestPermissionResponse): Promise<void> {
|
||||
if (!this.pendingPermissionRequests.has(permissionId)) {
|
||||
throw new Error(`permission '${permissionId}' not found`);
|
||||
}
|
||||
this.resolvePendingPermission(permissionId, clonePermissionResponse(response));
|
||||
}
|
||||
|
||||
async getHealth(): Promise<HealthResponse> {
|
||||
return this.requestHealth();
|
||||
}
|
||||
|
|
@ -1301,9 +1469,22 @@ export class SandboxAgent {
|
|||
}
|
||||
|
||||
async getAgent(agent: string, options?: AgentQueryOptions): Promise<AgentInfo> {
|
||||
return this.requestJson("GET", `${API_PREFIX}/agents/${encodeURIComponent(agent)}`, {
|
||||
query: toAgentQuery(options),
|
||||
});
|
||||
try {
|
||||
return await this.requestJson("GET", `${API_PREFIX}/agents/${encodeURIComponent(agent)}`, {
|
||||
query: toAgentQuery(options),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof SandboxAgentError) || error.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const listed = await this.listAgents(options);
|
||||
const match = listed.agents.find((entry) => entry.id === agent);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async installAgent(agent: string, request: AgentInstallRequest = {}): Promise<AgentInstallResponse> {
|
||||
|
|
@ -1551,6 +1732,8 @@ export class SandboxAgent {
|
|||
onObservedEnvelope: (connection, envelope, direction, localSessionId) => {
|
||||
void this.persistObservedEnvelope(connection, envelope, direction, localSessionId);
|
||||
},
|
||||
onPermissionRequest: async (connection, localSessionId, agentSessionId, request) =>
|
||||
this.enqueuePermissionRequest(connection, localSessionId, agentSessionId, request),
|
||||
});
|
||||
|
||||
const raced = this.liveConnections.get(agent);
|
||||
|
|
@ -1753,6 +1936,69 @@ export class SandboxAgent {
|
|||
return record;
|
||||
}
|
||||
|
||||
private async enqueuePermissionRequest(
|
||||
_connection: LiveAcpConnection,
|
||||
localSessionId: string,
|
||||
agentSessionId: string,
|
||||
request: RequestPermissionRequest,
|
||||
): Promise<RequestPermissionResponse> {
|
||||
const listeners = this.permissionListeners.get(localSessionId);
|
||||
if (!listeners || listeners.size === 0) {
|
||||
return cancelledPermissionResponse();
|
||||
}
|
||||
|
||||
const pendingId = randomId();
|
||||
const permissionRequest: SessionPermissionRequest = {
|
||||
id: pendingId,
|
||||
createdAt: nowMs(),
|
||||
sessionId: localSessionId,
|
||||
agentSessionId,
|
||||
availableReplies: availablePermissionReplies(request.options),
|
||||
options: request.options.map(clonePermissionOption),
|
||||
toolCall: clonePermissionToolCall(request.toolCall),
|
||||
rawRequest: clonePermissionRequest(request),
|
||||
};
|
||||
|
||||
return await new Promise<RequestPermissionResponse>((resolve, reject) => {
|
||||
this.pendingPermissionRequests.set(pendingId, {
|
||||
id: pendingId,
|
||||
sessionId: localSessionId,
|
||||
request: clonePermissionRequest(request),
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
|
||||
try {
|
||||
for (const listener of listeners) {
|
||||
listener(permissionRequest);
|
||||
}
|
||||
} catch (error) {
|
||||
this.pendingPermissionRequests.delete(pendingId);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resolvePendingPermission(permissionId: string, response: RequestPermissionResponse): void {
|
||||
const pending = this.pendingPermissionRequests.get(permissionId);
|
||||
if (!pending) {
|
||||
throw new Error(`permission '${permissionId}' not found`);
|
||||
}
|
||||
|
||||
this.pendingPermissionRequests.delete(permissionId);
|
||||
pending.resolve(response);
|
||||
}
|
||||
|
||||
private cancelPendingPermissionsForSession(sessionId: string): void {
|
||||
for (const [permissionId, pending] of this.pendingPermissionRequests) {
|
||||
if (pending.sessionId !== sessionId) {
|
||||
continue;
|
||||
}
|
||||
this.pendingPermissionRequests.delete(permissionId);
|
||||
pending.resolve(cancelledPermissionResponse());
|
||||
}
|
||||
}
|
||||
|
||||
private async requestJson<T>(method: string, path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const response = await this.requestRaw(method, path, {
|
||||
query: options.query,
|
||||
|
|
@ -1922,6 +2168,14 @@ export class SandboxAgent {
|
|||
}
|
||||
}
|
||||
|
||||
type PendingPermissionRequestState = {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
request: RequestPermissionRequest;
|
||||
resolve: (response: RequestPermissionResponse) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
};
|
||||
|
||||
type QueryValue = string | number | boolean | null | undefined;
|
||||
|
||||
type RequestOptions = {
|
||||
|
|
@ -2166,6 +2420,26 @@ function cloneEnvelope(envelope: AnyMessage): AnyMessage {
|
|||
return JSON.parse(JSON.stringify(envelope)) as AnyMessage;
|
||||
}
|
||||
|
||||
function clonePermissionRequest(request: RequestPermissionRequest): RequestPermissionRequest {
|
||||
return JSON.parse(JSON.stringify(request)) as RequestPermissionRequest;
|
||||
}
|
||||
|
||||
function clonePermissionResponse(response: RequestPermissionResponse): RequestPermissionResponse {
|
||||
return JSON.parse(JSON.stringify(response)) as RequestPermissionResponse;
|
||||
}
|
||||
|
||||
function clonePermissionOption(option: PermissionOption): SessionPermissionRequestOption {
|
||||
return {
|
||||
optionId: option.optionId,
|
||||
name: option.name,
|
||||
kind: option.kind,
|
||||
};
|
||||
}
|
||||
|
||||
function clonePermissionToolCall(toolCall: RequestPermissionRequest["toolCall"]): RequestPermissionRequest["toolCall"] {
|
||||
return JSON.parse(JSON.stringify(toolCall)) as RequestPermissionRequest["toolCall"];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, any> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
|
@ -2314,6 +2588,35 @@ function extractKnownModeIds(modes: SessionModeState | null | undefined): string
|
|||
.filter((value): value is string => !!value);
|
||||
}
|
||||
|
||||
function deriveModesFromConfigOptions(
|
||||
configOptions: SessionConfigOption[] | undefined,
|
||||
): SessionModeState | null {
|
||||
if (!configOptions || configOptions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const modeOption = findConfigOptionByCategory(configOptions, "mode");
|
||||
if (!modeOption || !Array.isArray(modeOption.options)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const availableModes = modeOption.options
|
||||
.flatMap((entry) => flattenConfigOptions(entry))
|
||||
.map((entry) => ({
|
||||
id: entry.value,
|
||||
name: entry.name,
|
||||
description: entry.description ?? null,
|
||||
}));
|
||||
|
||||
return {
|
||||
currentModeId:
|
||||
typeof modeOption.currentValue === "string" && modeOption.currentValue.length > 0
|
||||
? modeOption.currentValue
|
||||
: availableModes[0]?.id ?? "",
|
||||
availableModes,
|
||||
};
|
||||
}
|
||||
|
||||
function applyCurrentMode(
|
||||
modes: SessionModeState | null | undefined,
|
||||
currentModeId: string,
|
||||
|
|
@ -2344,6 +2647,25 @@ function applyConfigOptionValue(
|
|||
return updated;
|
||||
}
|
||||
|
||||
function flattenConfigOptions(entry: unknown): Array<{ value: string; name: string; description?: string }> {
|
||||
if (!isRecord(entry)) {
|
||||
return [];
|
||||
}
|
||||
if (typeof entry.value === "string" && typeof entry.name === "string") {
|
||||
return [
|
||||
{
|
||||
value: entry.value,
|
||||
name: entry.name,
|
||||
description: typeof entry.description === "string" ? entry.description : undefined,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (!Array.isArray(entry.options)) {
|
||||
return [];
|
||||
}
|
||||
return entry.options.flatMap((nested) => flattenConfigOptions(nested));
|
||||
}
|
||||
|
||||
function envelopeSessionUpdate(message: AnyMessage): Record<string, unknown> | null {
|
||||
if (!isRecord(message) || !("params" in message) || !isRecord(message.params)) {
|
||||
return null;
|
||||
|
|
@ -2368,6 +2690,60 @@ function cloneModes(value: SessionModeState | null | undefined): SessionModeStat
|
|||
return JSON.parse(JSON.stringify(value)) as SessionModeState;
|
||||
}
|
||||
|
||||
function availablePermissionReplies(options: PermissionOption[]): PermissionReply[] {
|
||||
const replies = new Set<PermissionReply>();
|
||||
for (const option of options) {
|
||||
if (option.kind === "allow_once") {
|
||||
replies.add("once");
|
||||
} else if (option.kind === "allow_always") {
|
||||
replies.add("always");
|
||||
} else if (option.kind === "reject_once" || option.kind === "reject_always") {
|
||||
replies.add("reject");
|
||||
}
|
||||
}
|
||||
return [...replies];
|
||||
}
|
||||
|
||||
function permissionReplyToResponse(
|
||||
permissionId: string,
|
||||
request: RequestPermissionRequest,
|
||||
reply: PermissionReply,
|
||||
): RequestPermissionResponse {
|
||||
const preferredKinds: PermissionOptionKind[] =
|
||||
reply === "once"
|
||||
? ["allow_once"]
|
||||
: reply === "always"
|
||||
? ["allow_always", "allow_once"]
|
||||
: ["reject_once", "reject_always"];
|
||||
|
||||
const selected = preferredKinds
|
||||
.map((kind) => request.options.find((option) => option.kind === kind))
|
||||
.find((option): option is PermissionOption => Boolean(option));
|
||||
|
||||
if (!selected) {
|
||||
throw new UnsupportedPermissionReplyError(
|
||||
permissionId,
|
||||
reply,
|
||||
availablePermissionReplies(request.options),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
outcome: {
|
||||
outcome: "selected",
|
||||
optionId: selected.optionId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function cancelledPermissionResponse(): RequestPermissionResponse {
|
||||
return {
|
||||
outcome: {
|
||||
outcome: "cancelled",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isSessionConfigOption(value: unknown): value is SessionConfigOption {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export {
|
|||
SandboxAgent,
|
||||
SandboxAgentError,
|
||||
Session,
|
||||
UnsupportedPermissionReplyError,
|
||||
UnsupportedSessionCategoryError,
|
||||
UnsupportedSessionConfigOptionError,
|
||||
UnsupportedSessionValueError,
|
||||
|
|
@ -28,6 +29,10 @@ export type {
|
|||
SessionResumeOrCreateRequest,
|
||||
SessionSendOptions,
|
||||
SessionEventListener,
|
||||
PermissionReply,
|
||||
PermissionRequestListener,
|
||||
SessionPermissionRequest,
|
||||
SessionPermissionRequestOption,
|
||||
} from "./client.ts";
|
||||
|
||||
export type { InspectorUrlOptions } from "./inspector.ts";
|
||||
|
|
|
|||
|
|
@ -25,10 +25,12 @@ export function prepareMockAgentDataHome(dataHome: string): Record<string, strin
|
|||
runtimeEnv.XDG_DATA_HOME = dataHome;
|
||||
}
|
||||
|
||||
const nodeScript = String.raw`#!/usr/bin/env node
|
||||
const nodeScript = String.raw`#!/usr/bin/env node
|
||||
const { createInterface } = require("node:readline");
|
||||
|
||||
let nextSession = 0;
|
||||
let nextPermission = 0;
|
||||
const pendingPermissions = new Map();
|
||||
|
||||
function emit(value) {
|
||||
process.stdout.write(JSON.stringify(value) + "\n");
|
||||
|
|
@ -65,6 +67,38 @@ rl.on("line", (line) => {
|
|||
const hasId = Object.prototype.hasOwnProperty.call(msg, "id");
|
||||
const method = hasMethod ? msg.method : undefined;
|
||||
|
||||
if (!hasMethod && hasId) {
|
||||
const pending = pendingPermissions.get(String(msg.id));
|
||||
if (pending) {
|
||||
pendingPermissions.delete(String(msg.id));
|
||||
const outcome = msg?.result?.outcome;
|
||||
const optionId = outcome?.outcome === "selected" ? outcome.optionId : "cancelled";
|
||||
const suffix = optionId === "reject-once" ? "rejected" : "approved";
|
||||
emit({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: pending.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "mock permission " + suffix + ": " + optionId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
emit({
|
||||
jsonrpc: "2.0",
|
||||
id: pending.promptId,
|
||||
result: {
|
||||
stopReason: "end_turn",
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "session/prompt") {
|
||||
const sessionId = typeof msg?.params?.sessionId === "string" ? msg.params.sessionId : "";
|
||||
const text = firstText(msg?.params?.prompt);
|
||||
|
|
@ -82,6 +116,51 @@ rl.on("line", (line) => {
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (text.includes("permission")) {
|
||||
nextPermission += 1;
|
||||
const permissionId = "permission-" + nextPermission;
|
||||
pendingPermissions.set(permissionId, {
|
||||
promptId: msg.id,
|
||||
sessionId,
|
||||
});
|
||||
emit({
|
||||
jsonrpc: "2.0",
|
||||
id: permissionId,
|
||||
method: "session/request_permission",
|
||||
params: {
|
||||
sessionId,
|
||||
toolCall: {
|
||||
toolCallId: "tool-call-" + nextPermission,
|
||||
title: "Write mock.txt",
|
||||
kind: "edit",
|
||||
status: "pending",
|
||||
locations: [{ path: "/tmp/mock.txt" }],
|
||||
rawInput: {
|
||||
path: "/tmp/mock.txt",
|
||||
content: "hello",
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
kind: "allow_once",
|
||||
name: "Allow once",
|
||||
optionId: "allow-once",
|
||||
},
|
||||
{
|
||||
kind: "allow_always",
|
||||
name: "Always allow",
|
||||
optionId: "allow-always",
|
||||
},
|
||||
{
|
||||
kind: "reject_once",
|
||||
name: "Reject",
|
||||
optionId: "reject-once",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasMethod || !hasId) {
|
||||
|
|
@ -117,6 +196,10 @@ rl.on("line", (line) => {
|
|||
}
|
||||
|
||||
if (method === "session/prompt") {
|
||||
const text = firstText(msg?.params?.prompt);
|
||||
if (text.includes("permission")) {
|
||||
return;
|
||||
}
|
||||
emit({
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id,
|
||||
|
|
|
|||
|
|
@ -512,10 +512,10 @@ describe("Integration: TypeScript SDK flat session API", () => {
|
|||
|
||||
const session = await sdk.createSession({ agent: "mock" });
|
||||
|
||||
await expect(session.send("session/cancel")).rejects.toThrow(
|
||||
await expect(session.rawSend("session/cancel")).rejects.toThrow(
|
||||
"Use destroySession(sessionId) instead.",
|
||||
);
|
||||
await expect(sdk.sendSessionMethod(session.id, "session/cancel", {})).rejects.toThrow(
|
||||
await expect(sdk.rawSendSessionMethod(session.id, "session/cancel", {})).rejects.toThrow(
|
||||
"Use destroySession(sessionId) instead.",
|
||||
);
|
||||
|
||||
|
|
@ -625,6 +625,43 @@ describe("Integration: TypeScript SDK flat session API", () => {
|
|||
await sdk.dispose();
|
||||
});
|
||||
|
||||
it("surfaces ACP permission requests and maps approve/reject replies", async () => {
|
||||
const sdk = await SandboxAgent.connect({
|
||||
baseUrl,
|
||||
token,
|
||||
});
|
||||
|
||||
const session = await sdk.createSession({ agent: "mock" });
|
||||
const permissionIds: string[] = [];
|
||||
const permissionTexts: string[] = [];
|
||||
|
||||
const offPermissions = session.onPermissionRequest((request) => {
|
||||
permissionIds.push(request.id);
|
||||
const reply = permissionIds.length === 1 ? "reject" : "always";
|
||||
void session.respondPermission(request.id, reply);
|
||||
});
|
||||
|
||||
const offEvents = session.onEvent((event) => {
|
||||
const text = (event.payload as any)?.params?.update?.content?.text;
|
||||
if (typeof text === "string" && text.startsWith("mock permission ")) {
|
||||
permissionTexts.push(text);
|
||||
}
|
||||
});
|
||||
|
||||
await session.prompt([{ type: "text", text: "trigger permission request one" }]);
|
||||
await session.prompt([{ type: "text", text: "trigger permission request two" }]);
|
||||
|
||||
await waitFor(() => (permissionIds.length === 2 ? permissionIds : undefined));
|
||||
await waitFor(() => (permissionTexts.length === 2 ? permissionTexts : undefined));
|
||||
|
||||
expect(permissionTexts[0]).toContain("rejected");
|
||||
expect(permissionTexts[1]).toContain("approved");
|
||||
|
||||
offEvents();
|
||||
offPermissions();
|
||||
await sdk.dispose();
|
||||
});
|
||||
|
||||
it("supports MCP and skills config HTTP helpers", async () => {
|
||||
const sdk = await SandboxAgent.connect({
|
||||
baseUrl,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue