chore: recover hamburg workspace state

This commit is contained in:
Nathan Flurry 2026-03-09 19:59:42 -07:00
parent 5d65013aa5
commit 196541394b
15 changed files with 1082 additions and 60 deletions

View file

@ -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);
}
}

View file

@ -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();
});
});

View file

@ -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,
@ -125,6 +129,7 @@ export interface SessionCreateRequest {
sessionInit?: Omit<NewSessionRequest, "_meta">;
model?: string;
mode?: string;
permissionMode?: string;
thoughtLevel?: string;
}
@ -134,6 +139,7 @@ export interface SessionResumeOrCreateRequest {
sessionInit?: Omit<NewSessionRequest, "_meta">;
model?: string;
mode?: string;
permissionMode?: string;
thoughtLevel?: string;
}
@ -142,9 +148,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 +263,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;
@ -297,6 +338,14 @@ export class Session {
return updated.response;
}
async setPermissionMode(
permissionMode: string,
): Promise<SetSessionModeResponse | SetSessionConfigOptionResponse | void> {
const updated = await this.sandbox.setSessionPermissionMode(this.id, permissionMode);
this.apply(updated.session.toRecord());
return updated.response;
}
async setConfigOption(configId: string, value: string): Promise<SetSessionConfigOptionResponse> {
const updated = await this.sandbox.setSessionConfigOption(this.id, configId, value);
this.apply(updated.session.toRecord());
@ -327,6 +376,18 @@ export class Session {
return this.sandbox.onSessionEvent(this.id, listener);
}
onPermissionRequest(listener: PermissionRequestListener): () => void {
return this.sandbox.onPermissionRequest(this.id, listener);
}
async replyPermission(permissionId: string, reply: PermissionReply): Promise<void> {
await this.sandbox.replyPermission(permissionId, reply);
}
async respondToPermission(permissionId: string, response: RequestPermissionResponse): Promise<void> {
await this.sandbox.respondToPermission(permissionId, response);
}
toRecord(): SessionRecord {
return { ...this.record };
}
@ -355,6 +416,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 +433,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 +460,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 +480,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 +502,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 +642,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 +891,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 +951,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()];
@ -910,11 +1026,15 @@ export class SandboxAgent {
this.nextSessionEventIndexBySession.set(record.id, 1);
live.bindSession(record.id, record.agentSessionId);
let session = this.upsertSessionHandle(record);
assertNoConflictingPermissionMode(request.mode, request.permissionMode);
try {
if (request.mode) {
session = (await this.setSessionMode(session.id, request.mode)).session;
}
if (request.permissionMode) {
session = (await this.setSessionPermissionMode(session.id, request.permissionMode)).session;
}
if (request.model) {
session = (await this.setSessionModel(session.id, request.model)).session;
}
@ -969,9 +1089,13 @@ export class SandboxAgent {
const existing = await this.persist.getSession(request.id);
if (existing) {
let session = await this.resumeSession(existing.id);
assertNoConflictingPermissionMode(request.mode, request.permissionMode);
if (request.mode) {
session = (await this.setSessionMode(session.id, request.mode)).session;
}
if (request.permissionMode) {
session = (await this.setSessionPermissionMode(session.id, request.permissionMode)).session;
}
if (request.model) {
session = (await this.setSessionModel(session.id, request.model)).session;
}
@ -984,6 +1108,8 @@ export class SandboxAgent {
}
async destroySession(id: string): Promise<Session> {
this.cancelPendingPermissionsForSession(id);
try {
await this.sendSessionMethodInternal(id, SESSION_CANCEL_METHOD, {}, {}, true);
} catch {
@ -1085,6 +1211,24 @@ export class SandboxAgent {
return this.setSessionCategoryValue(sessionId, "model", model);
}
async setSessionPermissionMode(
sessionId: string,
permissionMode: string,
): Promise<{ session: Session; response: SetSessionModeResponse | SetSessionConfigOptionResponse | void }> {
const resolvedValue = permissionMode.trim();
if (!resolvedValue) {
throw new Error("setSessionPermissionMode requires a non-empty permissionMode");
}
const options = await this.getSessionConfigOptions(sessionId);
const permissionOption = findConfigOptionByCategory(options, "permission_mode");
if (permissionOption) {
return this.setSessionConfigOption(sessionId, permissionOption.id, resolvedValue);
}
return this.setSessionMode(sessionId, resolvedValue);
}
async setSessionThoughtLevel(
sessionId: string,
thoughtLevel: string,
@ -1100,7 +1244,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(
@ -1135,7 +1298,7 @@ export class SandboxAgent {
}
private async hydrateSessionConfigOptions(sessionId: string, snapshot: SessionRecord): Promise<SessionRecord> {
if (snapshot.configOptions !== undefined) {
if (snapshot.configOptions !== undefined && snapshot.configOptions.length > 0) {
return snapshot;
}
@ -1290,6 +1453,40 @@ 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 replyPermission(permissionId: string, reply: PermissionReply): Promise<void> {
const pending = this.pendingPermissionRequests.get(permissionId);
if (!pending) {
throw new Error(`permission '${permissionId}' not found`);
}
const response = permissionReplyToResponse(permissionId, pending.request, reply);
this.resolvePendingPermission(permissionId, response);
}
async respondToPermission(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 +1498,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 +1761,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 +1965,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 +2197,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 +2449,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 +2617,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 +2676,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 +2719,70 @@ function cloneModes(value: SessionModeState | null | undefined): SessionModeStat
return JSON.parse(JSON.stringify(value)) as SessionModeState;
}
function assertNoConflictingPermissionMode(mode: string | undefined, permissionMode: string | undefined): void {
if (!mode || !permissionMode) {
return;
}
if (mode.trim() === permissionMode.trim()) {
return;
}
throw new Error("createSession/resumeOrCreate received conflicting values for mode and permissionMode");
}
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", "allow_always"]
: 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) &&

View file

@ -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";

View file

@ -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,

View file

@ -578,6 +578,42 @@ describe("Integration: TypeScript SDK flat session API", () => {
await sdk.dispose();
});
it("supports permissionMode as a first-class session helper", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
const session = await sdk.createSession({
agent: "mock",
permissionMode: "plan",
});
expect((await session.getModes())?.currentModeId).toBe("plan");
await session.setPermissionMode("normal");
expect((await session.getModes())?.currentModeId).toBe("normal");
await sdk.dispose();
});
it("rejects conflicting mode and permissionMode values", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
await expect(
sdk.createSession({
agent: "mock",
mode: "normal",
permissionMode: "plan",
}),
).rejects.toThrow("conflicting values");
await sdk.dispose();
});
it("setThoughtLevel happy path switches to a valid thought level", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
@ -625,6 +661,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.replyPermission(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,