fix: wire gigacode --yolo through opencode session permissionMode

This commit is contained in:
Nathan Flurry 2026-02-07 03:08:07 -08:00
parent 4bdd2416d1
commit 63625ee48f
16 changed files with 734 additions and 124 deletions

View file

@ -41,6 +41,10 @@ Events / Message Flow
| error | SDKResultMessage.error | method=error | type=session.error (or message error) | type=error |
+------------------------+------------------------------+--------------------------------------------+-----------------------------------------+----------------------------------+
Permission status normalization:
- `permission.requested` uses `status=requested`.
- `permission.resolved` uses `status=accept`, `accept_for_session`, or `reject`.
Synthetics
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+

View file

@ -1408,8 +1408,9 @@
"type": "string",
"enum": [
"requested",
"approved",
"denied"
"accept",
"accept_for_session",
"reject"
]
},
"ProblemDetails": {

View file

@ -158,7 +158,7 @@ Items follow a consistent lifecycle: `item.started` → `item.delta` (0 or more)
| Type | Description | Data |
|------|-------------|------|
| `permission.requested` | Permission request pending | `{ permission_id, action, status, metadata? }` |
| `permission.resolved` | Permission granted or denied | `{ permission_id, action, status, metadata? }` |
| `permission.resolved` | Permission decision recorded | `{ permission_id, action, status, metadata? }` |
| `question.requested` | Question pending user input | `{ question_id, prompt, options, status }` |
| `question.resolved` | Question answered or rejected | `{ question_id, prompt, options, status, response? }` |
@ -168,7 +168,7 @@ Items follow a consistent lifecycle: `item.started` → `item.delta` (0 or more)
|-------|------|-------------|
| `permission_id` | string | Identifier for the permission request |
| `action` | string | What the agent wants to do |
| `status` | string | `requested`, `approved`, `denied` |
| `status` | string | `requested`, `accept`, `accept_for_session`, `reject` |
| `metadata` | any? | Additional context |
**QuestionEventData**

View file

@ -17,9 +17,19 @@ fn run() -> Result<(), CliError> {
no_token: cli.no_token,
gigacode: true,
};
let command = cli
.command
.unwrap_or_else(|| Command::Opencode(OpencodeArgs::default()));
let yolo = cli.yolo;
let command = match cli.command {
Some(Command::Opencode(mut args)) => {
args.yolo = args.yolo || yolo;
Command::Opencode(args)
}
Some(other) => other,
None => {
let mut args = OpencodeArgs::default();
args.yolo = yolo;
Command::Opencode(args)
}
};
if let Err(err) = init_logging(&command) {
eprintln!("failed to init logging: {err}");
return Err(err);

View file

@ -232,7 +232,7 @@ export interface components {
reply: components["schemas"]["PermissionReply"];
};
/** @enum {string} */
PermissionStatus: "requested" | "approved" | "denied";
PermissionStatus: "requested" | "accept" | "accept_for_session" | "reject";
ProblemDetails: {
detail?: string | null;
instance?: string | null;

View file

@ -68,6 +68,10 @@ pub struct GigacodeCli {
#[arg(long, short = 'n', global = true)]
pub no_token: bool,
/// Bypass all permission checks (auto-approve tool calls).
#[arg(long, global = true)]
pub yolo: bool,
}
#[derive(Subcommand, Debug)]
@ -126,6 +130,10 @@ pub struct OpencodeArgs {
#[arg(long)]
session_title: Option<String>,
/// Bypass all permission checks (auto-approve tool calls).
#[arg(long)]
pub yolo: bool,
}
impl Default for OpencodeArgs {
@ -134,6 +142,7 @@ impl Default for OpencodeArgs {
host: DEFAULT_HOST.to_string(),
port: DEFAULT_PORT,
session_title: None,
yolo: false,
}
}
}
@ -592,13 +601,18 @@ fn run_opencode(cli: &CliConfig, args: &OpencodeArgs) -> Result<(), CliError> {
};
write_stderr_line(&format!("\nEXPERIMENTAL: Please report bugs to:\n- GitHub: https://github.com/rivet-dev/sandbox-agent/issues\n- Discord: https://rivet.dev/discord\n\n{name} is powered by:\n- OpenCode (TUI): https://opencode.ai/\n- Sandbox Agent SDK (multi-agent compatibility): https://sandboxagent.dev/\n\n"))?;
let yolo = args.yolo;
let token = cli.token.clone();
let base_url = format!("http://{}:{}", args.host, args.port);
crate::daemon::ensure_running(cli, &args.host, args.port, token.as_deref())?;
let session_id =
create_opencode_session(&base_url, token.as_deref(), args.session_title.as_deref())?;
let session_id = create_opencode_session(
&base_url,
token.as_deref(),
args.session_title.as_deref(),
yolo,
)?;
write_stdout_line(&format!("OpenCode session: {session_id}"))?;
let attach_url = format!("{base_url}/opencode");
@ -807,14 +821,18 @@ fn create_opencode_session(
base_url: &str,
token: Option<&str>,
title: Option<&str>,
yolo: bool,
) -> Result<String, CliError> {
let client = HttpClient::builder().build()?;
let url = format!("{base_url}/opencode/session");
let body = if let Some(title) = title {
let mut body = if let Some(title) = title {
json!({ "title": title })
} else {
json!({})
};
if yolo {
body["permissionMode"] = json!("bypass");
}
let mut request = client.post(&url).json(&body);
if let Ok(directory) = std::env::current_dir() {
request = request.header(

View file

@ -115,6 +115,7 @@ struct OpenCodeSessionRecord {
created_at: i64,
updated_at: i64,
share_url: Option<String>,
permission_mode: Option<String>,
}
impl OpenCodeSessionRecord {
@ -370,6 +371,7 @@ impl OpenCodeState {
created_at: now,
updated_at: now,
share_url: None,
permission_mode: None,
};
let value = record.to_value();
sessions.insert(session_id.to_string(), record);
@ -434,13 +436,14 @@ async fn ensure_backing_session(
agent: &str,
model: Option<String>,
variant: Option<String>,
permission_mode: Option<String>,
) -> Result<(), SandboxError> {
let model = model.filter(|value| !value.trim().is_empty());
let variant = variant.filter(|value| !value.trim().is_empty());
let request = CreateSessionRequest {
agent: agent.to_string(),
agent_mode: None,
permission_mode: None,
permission_mode,
model: model.clone(),
variant: variant.clone(),
agent_version: None,
@ -520,6 +523,8 @@ struct OpenCodeCreateSessionRequest {
parent_id: Option<String>,
#[schema(value_type = String)]
permission: Option<Value>,
#[serde(alias = "permission_mode")]
permission_mode: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
@ -1936,10 +1941,13 @@ async fn apply_permission_event(
.opencode
.emit_event(permission_event("permission.asked", &value));
}
PermissionStatus::Approved | PermissionStatus::Denied => {
PermissionStatus::Accept
| PermissionStatus::AcceptForSession
| PermissionStatus::Reject => {
let reply = match permission.status {
PermissionStatus::Approved => "once",
PermissionStatus::Denied => "reject",
PermissionStatus::Accept => "once",
PermissionStatus::AcceptForSession => "always",
PermissionStatus::Reject => "reject",
PermissionStatus::Requested => "once",
};
let event_value = json!({
@ -2700,9 +2708,7 @@ async fn apply_item_delta(
runtime
.part_id_by_message
.insert(message_id.clone(), part_id.clone());
runtime
.messages_with_text_deltas
.insert(message_id.clone());
runtime.messages_with_text_deltas.insert(message_id.clone());
})
.await;
}
@ -3354,6 +3360,7 @@ async fn oc_session_create(
title: None,
parent_id: None,
permission: None,
permission_mode: None,
});
let directory = state
.opencode
@ -3362,6 +3369,7 @@ async fn oc_session_create(
let id = next_id("ses_", &SESSION_COUNTER);
let slug = format!("session-{}", id);
let title = body.title.unwrap_or_else(|| format!("Session {}", id));
let permission_mode = body.permission_mode;
let record = OpenCodeSessionRecord {
id: id.clone(),
slug,
@ -3373,6 +3381,7 @@ async fn oc_session_create(
created_at: now,
updated_at: now,
share_url: None,
permission_mode,
};
let session_value = record.to_value();
@ -3541,6 +3550,12 @@ async fn oc_session_fork(
let id = next_id("ses_", &SESSION_COUNTER);
let slug = format!("session-{}", id);
let title = format!("Fork of {}", session_id);
let parent_permission_mode = {
let sessions = state.opencode.sessions.lock().await;
sessions
.get(&session_id)
.and_then(|s| s.permission_mode.clone())
};
let record = OpenCodeSessionRecord {
id: id.clone(),
slug,
@ -3552,6 +3567,7 @@ async fn oc_session_fork(
created_at: now,
updated_at: now,
share_url: None,
permission_mode: parent_permission_mode,
};
let value = record.to_value();
@ -3722,12 +3738,20 @@ async fn oc_session_message_create(
})
.await;
let session_permission_mode = {
let sessions = state.opencode.sessions.lock().await;
sessions
.get(&session_id)
.and_then(|s| s.permission_mode.clone())
};
if let Err(err) = ensure_backing_session(
&state,
&session_id,
&session_agent,
backing_model,
backing_variant,
session_permission_mode,
)
.await
{

View file

@ -417,6 +417,7 @@ struct SessionState {
events: Vec<UniversalEvent>,
pending_questions: HashMap<String, PendingQuestion>,
pending_permissions: HashMap<String, PendingPermission>,
always_allow_actions: HashSet<String>,
item_started: HashSet<String>,
item_delta_seen: HashSet<String>,
item_map: HashMap<String, String>,
@ -475,6 +476,7 @@ impl SessionState {
events: Vec::new(),
pending_questions: HashMap::new(),
pending_permissions: HashMap::new(),
always_allow_actions: HashSet::new(),
item_started: HashSet::new(),
item_delta_seen: HashSet::new(),
item_map: HashMap::new(),
@ -796,6 +798,18 @@ impl SessionState {
self.pending_permissions.remove(permission_id)
}
fn remember_permission_allow_for_session(&mut self, action: &str, metadata: &Option<Value>) {
for key in permission_cache_keys(action, metadata) {
self.always_allow_actions.insert(key);
}
}
fn should_auto_approve_permission(&self, action: &str, metadata: &Option<Value>) -> bool {
permission_cache_keys(action, metadata)
.iter()
.any(|key| self.always_allow_actions.contains(key))
}
/// Find and remove a pending permission whose action matches a question tool
/// (AskUserQuestion or ExitPlanMode variants). Returns (permission_id, PendingPermission).
fn take_question_tool_permission(&mut self) -> Option<(String, PendingPermission)> {
@ -2334,7 +2348,7 @@ impl SessionManager {
UniversalEventData::Permission(PermissionEventData {
permission_id: perm_id,
action: perm.action,
status: PermissionStatus::Approved,
status: PermissionStatus::Accept,
metadata: perm.metadata,
}),
)
@ -2454,7 +2468,7 @@ impl SessionManager {
UniversalEventData::Permission(PermissionEventData {
permission_id: perm_id,
action: perm.action,
status: PermissionStatus::Denied,
status: PermissionStatus::Reject,
metadata: perm.metadata,
}),
)
@ -2489,6 +2503,12 @@ impl SessionManager {
message: format!("unknown permission id: {permission_id}"),
});
}
if matches!(reply_for_status, PermissionReply::Always) {
if let Some(pending) = pending.as_ref() {
session
.remember_permission_allow_for_session(&pending.action, &pending.metadata);
}
}
if let Some(err) = session.ended_error() {
return Err(err);
}
@ -2610,8 +2630,9 @@ impl SessionManager {
if let Some(pending) = pending_permission {
let status = match reply_for_status {
PermissionReply::Reject => PermissionStatus::Denied,
PermissionReply::Once | PermissionReply::Always => PermissionStatus::Approved,
PermissionReply::Reject => PermissionStatus::Reject,
PermissionReply::Once => PermissionStatus::Accept,
PermissionReply::Always => PermissionStatus::AcceptForSession,
};
let resolved = EventConversion::new(
UniversalEventType::PermissionResolved,
@ -2910,13 +2931,127 @@ impl SessionManager {
session_id: &str,
conversions: Vec<EventConversion>,
) -> Result<Vec<UniversalEvent>, SandboxError> {
let mut sessions = self.sessions.lock().await;
let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| {
SandboxError::SessionNotFound {
session_id: session_id.to_string(),
let (events, auto_approvals) = {
let mut sessions = self.sessions.lock().await;
let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| {
SandboxError::SessionNotFound {
session_id: session_id.to_string(),
}
})?;
let events = session.record_conversions(conversions);
let mut auto_approvals = Vec::new();
for event in &events {
if event.event_type != UniversalEventType::PermissionRequested {
continue;
}
let UniversalEventData::Permission(data) = &event.data else {
continue;
};
let cached = session.should_auto_approve_permission(&data.action, &data.metadata);
if session.agent == AgentId::Codex
|| is_question_tool_action(&data.action)
|| !cached
{
continue;
}
if let Some(pending) = session.take_permission(&data.permission_id) {
auto_approvals.push((
session.agent,
session.native_session_id.clone(),
session.claude_sender(),
data.permission_id.clone(),
pending,
));
}
}
})?;
Ok(session.record_conversions(conversions))
(events, auto_approvals)
};
for (agent, native_session_id, claude_sender, permission_id, pending) in auto_approvals {
let reply_result = match agent {
AgentId::Opencode => {
let agent_session_id =
native_session_id
.clone()
.ok_or_else(|| SandboxError::InvalidRequest {
message: "missing OpenCode session id".to_string(),
});
match agent_session_id {
Ok(agent_session_id) => {
self.opencode_permission_reply(
&agent_session_id,
&permission_id,
PermissionReply::Always,
)
.await
}
Err(err) => Err(err),
}
}
AgentId::Claude => {
let sender = claude_sender.ok_or_else(|| SandboxError::InvalidRequest {
message: "Claude session is not active".to_string(),
});
match sender {
Ok(sender) => {
let metadata = pending.metadata.as_ref().and_then(Value::as_object);
let updated_input = metadata
.and_then(|map| map.get("input"))
.cloned()
.unwrap_or(Value::Null);
let mut response_map = serde_json::Map::new();
if !updated_input.is_null() {
response_map.insert("updatedInput".to_string(), updated_input);
}
let line = claude_control_response_line(
&permission_id,
"allow",
Value::Object(response_map),
);
sender.send(line).map_err(|_| SandboxError::InvalidRequest {
message: "Claude session is not active".to_string(),
})
}
Err(err) => Err(err),
}
}
_ => Ok(()),
};
if let Err(err) = reply_result {
tracing::warn!(
session_id,
permission_id,
?err,
"failed to auto-approve cached permission"
);
let mut sessions = self.sessions.lock().await;
if let Some(session) = Self::session_mut(&mut sessions, session_id) {
session
.pending_permissions
.insert(permission_id.clone(), pending.clone());
}
continue;
}
let resolved = EventConversion::new(
UniversalEventType::PermissionResolved,
UniversalEventData::Permission(PermissionEventData {
permission_id: permission_id.clone(),
action: pending.action,
status: PermissionStatus::AcceptForSession,
metadata: pending.metadata,
}),
)
.synthetic()
.with_native_session(native_session_id);
let mut sessions = self.sessions.lock().await;
if let Some(session) = Self::session_mut(&mut sessions, session_id) {
session.record_conversions(vec![resolved]);
}
}
Ok(events)
}
async fn parse_claude_line(&self, line: &str, session_id: &str) -> Vec<EventConversion> {
@ -5412,6 +5547,60 @@ pub(crate) fn is_question_tool_action(action: &str) -> bool {
)
}
fn permission_cache_keys(action: &str, metadata: &Option<Value>) -> Vec<String> {
let mut keys = Vec::new();
push_permission_cache_key(&mut keys, action);
if let Some(metadata) = metadata.as_ref().and_then(Value::as_object) {
if let Some(permission) = metadata.get("permission").and_then(Value::as_str) {
push_permission_cache_key(&mut keys, permission);
}
if let Some(kind) = metadata.get("codexRequestKind").and_then(Value::as_str) {
push_permission_cache_key(&mut keys, kind);
}
if let Some(tool_name) = metadata
.get("toolName")
.or_else(|| metadata.get("tool_name"))
.and_then(Value::as_str)
{
push_permission_cache_key(&mut keys, tool_name);
}
}
keys.sort_unstable();
keys.dedup();
keys
}
fn push_permission_cache_key(keys: &mut Vec<String>, raw: &str) {
let raw = raw.trim();
if raw.is_empty() {
return;
}
keys.push(raw.to_string());
if let Some(category) = permission_action_category(raw) {
keys.push(category);
}
}
fn permission_action_category(action: &str) -> Option<String> {
let first = action.split_whitespace().next().unwrap_or(action);
let stripped = first
.split_once(':')
.map(|(prefix, _)| prefix)
.unwrap_or(first)
.trim();
if stripped.is_empty() {
return None;
}
if stripped
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || ch == '.')
{
Some(stripped.to_ascii_lowercase())
} else {
None
}
}
fn read_lines<R: std::io::Read>(reader: R, sender: mpsc::UnboundedSender<String>) {
let mut reader = BufReader::new(reader);
let mut line = String::new();

View file

@ -53,6 +53,37 @@ describe("OpenCode-compatible Permission API", () => {
throw new Error("Timed out waiting for permission request");
}
async function waitForCondition(
check: () => boolean | Promise<boolean>,
timeoutMs = 10_000,
intervalMs = 100,
) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (await check()) {
return;
}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error("Timed out waiting for condition");
}
async function waitForValue<T>(
getValue: () => T | undefined | Promise<T | undefined>,
timeoutMs = 10_000,
intervalMs = 100,
): Promise<T> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const value = await getValue();
if (value !== undefined) {
return value;
}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error("Timed out waiting for value");
}
describe("permission.reply (global)", () => {
it("should receive permission.asked and reply via global endpoint", async () => {
await client.session.prompt({
@ -71,6 +102,108 @@ describe("OpenCode-compatible Permission API", () => {
});
expect(response.error).toBeUndefined();
});
it("should emit permission.replied with always when reply is always", async () => {
const eventStream = await client.event.subscribe();
const repliedEventPromise = new Promise<any>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Timed out waiting for permission.replied")), 15_000);
(async () => {
try {
for await (const event of (eventStream as any).stream) {
if (event.type === "permission.replied") {
clearTimeout(timeout);
resolve(event);
break;
}
}
} catch (err) {
clearTimeout(timeout);
reject(err);
}
})();
});
await client.session.prompt({
sessionID: sessionId,
model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: permissionPrompt }],
});
const asked = await waitForPermissionRequest();
const requestId = asked?.id;
expect(requestId).toBeDefined();
const response = await client.permission.reply({
requestID: requestId,
reply: "always",
});
expect(response.error).toBeUndefined();
const replied = await repliedEventPromise;
expect(replied?.properties?.requestID).toBe(requestId);
expect(replied?.properties?.reply).toBe("always");
});
it("should auto-reply subsequent matching permissions after always", async () => {
const eventStream = await client.event.subscribe();
const repliedEvents: any[] = [];
(async () => {
try {
for await (const event of (eventStream as any).stream) {
if (event.type === "permission.replied") {
repliedEvents.push(event);
}
}
} catch {
// Stream can end during test teardown.
}
})();
await client.session.prompt({
sessionID: sessionId,
model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: permissionPrompt }],
});
const firstAsked = await waitForPermissionRequest();
const firstRequestId = firstAsked?.id;
expect(firstRequestId).toBeDefined();
const firstReply = await client.permission.reply({
requestID: firstRequestId,
reply: "always",
});
expect(firstReply.error).toBeUndefined();
await waitForCondition(() =>
repliedEvents.some(
(event) =>
event?.properties?.requestID === firstRequestId &&
event?.properties?.reply === "always",
),
);
await client.session.prompt({
sessionID: sessionId,
model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: permissionPrompt }],
});
const autoReplyEvent = await waitForValue(() =>
repliedEvents.find(
(event) =>
event?.properties?.requestID !== firstRequestId &&
event?.properties?.reply === "always",
),
);
const autoRequestId = autoReplyEvent?.properties?.requestID;
expect(autoRequestId).toBeDefined();
await waitForCondition(async () => {
const list = await client.permission.list();
return !(list.data ?? []).some((item) => item?.id === autoRequestId);
});
});
});
describe("postSessionIdPermissionsPermissionId (session)", () => {

View file

@ -20,6 +20,29 @@ describe("OpenCode-compatible Session API", () => {
let handle: SandboxAgentHandle;
let client: OpencodeClient;
async function createSessionViaHttp(body: Record<string, unknown>) {
const response = await fetch(`${handle.baseUrl}/opencode/session`, {
method: "POST",
headers: {
Authorization: `Bearer ${handle.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
expect(response.ok).toBe(true);
return response.json();
}
async function getBackingSessionPermissionMode(sessionId: string) {
const response = await fetch(`${handle.baseUrl}/v1/sessions`, {
headers: { Authorization: `Bearer ${handle.token}` },
});
expect(response.ok).toBe(true);
const data = await response.json();
const session = (data.sessions ?? []).find((item: any) => item.sessionId === sessionId);
return session?.permissionMode;
}
beforeAll(async () => {
// Build the binary if needed
await buildSandboxAgent();
@ -63,6 +86,42 @@ describe("OpenCode-compatible Session API", () => {
expect(session1.data?.id).not.toBe(session2.data?.id);
});
it("should pass permissionMode bypass to backing session", async () => {
const session = await createSessionViaHttp({ permissionMode: "bypass" });
const sessionId = session.id as string;
expect(sessionId).toBeDefined();
const prompt = await client.session.prompt({
path: { id: sessionId },
body: {
model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "hello" }],
},
});
expect(prompt.error).toBeUndefined();
const permissionMode = await getBackingSessionPermissionMode(sessionId);
expect(permissionMode).toBe("bypass");
});
it("should accept permission_mode alias and pass bypass to backing session", async () => {
const session = await createSessionViaHttp({ permission_mode: "bypass" });
const sessionId = session.id as string;
expect(sessionId).toBeDefined();
const prompt = await client.session.prompt({
path: { id: sessionId },
body: {
model: { providerID: "mock", modelID: "mock" },
parts: [{ type: "text", text: "hello" }],
},
});
expect(prompt.error).toBeUndefined();
const permissionMode = await getBackingSessionPermissionMode(sessionId);
expect(permissionMode).toBe("bypass");
});
});
describe("session.list", () => {

View file

@ -80,3 +80,193 @@ async fn permission_flow_snapshots() {
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn permission_reply_always_sets_accept_for_session_status() {
let app = TestApp::new();
install_agent(&app.app, AgentId::Mock).await;
let session_id = "perm-always-mock";
create_session(&app.app, AgentId::Mock, session_id, "plan").await;
let status = send_status(
&app.app,
Method::POST,
&format!("/v1/sessions/{session_id}/messages"),
Some(json!({ "message": PERMISSION_PROMPT })),
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "send permission prompt");
let events = poll_events_until_match(&app.app, session_id, Duration::from_secs(30), |events| {
find_permission_id(events).is_some() || should_stop(events)
})
.await;
let permission_id = find_permission_id(&events).expect("permission.requested missing");
let status = send_status(
&app.app,
Method::POST,
&format!("/v1/sessions/{session_id}/permissions/{permission_id}/reply"),
Some(json!({ "reply": "always" })),
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "reply permission always");
let resolved_events =
poll_events_until_match(&app.app, session_id, Duration::from_secs(30), |events| {
events.iter().any(|event| {
event.get("type").and_then(Value::as_str) == Some("permission.resolved")
&& event
.get("data")
.and_then(|data| data.get("permission_id"))
.and_then(Value::as_str)
== Some(permission_id.as_str())
})
})
.await;
let resolved = resolved_events
.iter()
.rev()
.find(|event| {
event.get("type").and_then(Value::as_str) == Some("permission.resolved")
&& event
.get("data")
.and_then(|data| data.get("permission_id"))
.and_then(Value::as_str)
== Some(permission_id.as_str())
})
.expect("permission.resolved missing");
let status = resolved
.get("data")
.and_then(|data| data.get("status"))
.and_then(Value::as_str);
assert_eq!(status, Some("accept_for_session"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn permission_reply_always_auto_approves_subsequent_permissions() {
let app = TestApp::new();
install_agent(&app.app, AgentId::Mock).await;
let session_id = "perm-always-auto-mock";
create_session(&app.app, AgentId::Mock, session_id, "plan").await;
let first_status = send_status(
&app.app,
Method::POST,
&format!("/v1/sessions/{session_id}/messages"),
Some(json!({ "message": PERMISSION_PROMPT })),
)
.await;
assert_eq!(
first_status,
StatusCode::NO_CONTENT,
"send first permission prompt"
);
let first_events =
poll_events_until_match(&app.app, session_id, Duration::from_secs(30), |events| {
find_permission_id(events).is_some() || should_stop(events)
})
.await;
let first_permission_id =
find_permission_id(&first_events).expect("first permission.requested missing");
let reply_status = send_status(
&app.app,
Method::POST,
&format!("/v1/sessions/{session_id}/permissions/{first_permission_id}/reply"),
Some(json!({ "reply": "always" })),
)
.await;
assert_eq!(
reply_status,
StatusCode::NO_CONTENT,
"reply first permission always"
);
let second_status = send_status(
&app.app,
Method::POST,
&format!("/v1/sessions/{session_id}/messages"),
Some(json!({ "message": PERMISSION_PROMPT })),
)
.await;
assert_eq!(
second_status,
StatusCode::NO_CONTENT,
"send second permission prompt"
);
let events = poll_events_until_match(&app.app, session_id, Duration::from_secs(30), |events| {
let requested_ids = events
.iter()
.filter_map(|event| {
if event.get("type").and_then(Value::as_str) != Some("permission.requested") {
return None;
}
event
.get("data")
.and_then(|data| data.get("permission_id"))
.and_then(Value::as_str)
.map(|value| value.to_string())
})
.collect::<Vec<_>>();
if requested_ids.len() < 2 {
return false;
}
let second_permission_id = &requested_ids[1];
events.iter().any(|event| {
event.get("type").and_then(Value::as_str) == Some("permission.resolved")
&& event
.get("data")
.and_then(|data| data.get("permission_id"))
.and_then(Value::as_str)
== Some(second_permission_id.as_str())
&& event
.get("data")
.and_then(|data| data.get("status"))
.and_then(Value::as_str)
== Some("accept_for_session")
})
})
.await;
let requested_ids = events
.iter()
.filter_map(|event| {
if event.get("type").and_then(Value::as_str) != Some("permission.requested") {
return None;
}
event
.get("data")
.and_then(|data| data.get("permission_id"))
.and_then(Value::as_str)
.map(|value| value.to_string())
})
.collect::<Vec<_>>();
assert!(
requested_ids.len() >= 2,
"expected at least two permission.requested events"
);
let second_permission_id = &requested_ids[1];
let second_resolved = events.iter().any(|event| {
event.get("type").and_then(Value::as_str) == Some("permission.resolved")
&& event
.get("data")
.and_then(|data| data.get("permission_id"))
.and_then(Value::as_str)
== Some(second_permission_id.as_str())
&& event
.get("data")
.and_then(|data| data.get("status"))
.and_then(Value::as_str)
== Some("accept_for_session")
});
assert!(
second_resolved,
"second permission should auto-resolve as accept_for_session"
);
}

View file

@ -38,13 +38,36 @@ first:
status: in_progress
seq: 5
type: item.started
- item:
content_types: []
kind: message
role: assistant
status: completed
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 6
type: item.completed
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 7
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 8
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 9
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 10
type: item.delta
second:
- item:
content_types:

View file

@ -1,81 +0,0 @@
---
source: server/packages/sandbox-agent/tests/sessions/permissions.rs
assertion_line: 12
expression: value
---
- metadata: true
seq: 1
session: started
type: session.started
- item:
content_types:
- text
kind: message
role: user
status: in_progress
seq: 2
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 3
type: item.delta
- item:
content_types:
- text
kind: message
role: user
status: completed
seq: 4
type: item.completed
- item:
content_types:
- text
kind: message
role: assistant
status: in_progress
seq: 5
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 6
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 7
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 8
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 9
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 10
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 11
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 12
type: item.delta

View file

@ -91,9 +91,47 @@ expression: value
native_item_id: "<redacted>"
seq: 14
type: item.delta
- question:
id: "<redacted>"
options: 4
status: requested
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 15
type: question.requested
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 16
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 17
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 18
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 19
type: item.delta
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 20
type: item.delta
- item:
content_types:
- text
kind: message
role: assistant
status: completed
seq: 21
type: item.completed

View file

@ -161,8 +161,9 @@ pub struct PermissionEventData {
#[serde(rename_all = "snake_case")]
pub enum PermissionStatus {
Requested,
Approved,
Denied,
Accept,
AcceptForSession,
Reject,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]

View file

@ -370,8 +370,9 @@
"type": "string",
"enum": [
"requested",
"approved",
"denied"
"accept",
"accept_for_session",
"reject"
]
},
"QuestionEventData": {