story-kit: merge 180_bug_web_ui_permissions_handling_unreliable

This commit is contained in:
Dave
2026-02-26 17:08:32 +00:00
parent ac087f1a58
commit d908a54fc4
2 changed files with 38 additions and 21 deletions

View File

@@ -169,11 +169,13 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
}); });
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(null); const [claudeSessionId, setClaudeSessionId] = useState<string | null>(null);
const [activityStatus, setActivityStatus] = useState<string | null>(null); const [activityStatus, setActivityStatus] = useState<string | null>(null);
const [permissionRequest, setPermissionRequest] = useState<{ const [permissionQueue, setPermissionQueue] = useState<
{
requestId: string; requestId: string;
toolName: string; toolName: string;
toolInput: Record<string, unknown>; toolInput: Record<string, unknown>;
} | null>(null); }[]
>([]);
const [isNarrowScreen, setIsNarrowScreen] = useState( const [isNarrowScreen, setIsNarrowScreen] = useState(
window.innerWidth < NARROW_BREAKPOINT, window.innerWidth < NARROW_BREAKPOINT,
); );
@@ -320,7 +322,10 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
setPipeline(state); setPipeline(state);
}, },
onPermissionRequest: (requestId, toolName, toolInput) => { onPermissionRequest: (requestId, toolName, toolInput) => {
setPermissionRequest({ requestId, toolName, toolInput }); setPermissionQueue((prev) => [
...prev,
{ requestId, toolName, toolInput },
]);
}, },
onActivity: (toolName) => { onActivity: (toolName) => {
setActivityStatus(formatToolActivity(toolName)); setActivityStatus(formatToolActivity(toolName));
@@ -566,12 +571,10 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
}; };
const handlePermissionResponse = (approved: boolean) => { const handlePermissionResponse = (approved: boolean) => {
if (!permissionRequest) return; const current = permissionQueue[0];
wsRef.current?.sendPermissionResponse( if (!current) return;
permissionRequest.requestId, wsRef.current?.sendPermissionResponse(current.requestId, approved);
approved, setPermissionQueue((prev) => prev.slice(1));
);
setPermissionRequest(null);
}; };
const clearSession = async () => { const clearSession = async () => {
@@ -975,7 +978,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
</div> </div>
</div> </div>
)} )}
{permissionRequest && ( {permissionQueue.length > 0 && (
<div <div
style={{ style={{
position: "fixed", position: "fixed",
@@ -1002,6 +1005,17 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
> >
<h2 style={{ marginTop: 0, color: "#ececec" }}> <h2 style={{ marginTop: 0, color: "#ececec" }}>
Permission Request Permission Request
{permissionQueue.length > 1 && (
<span
style={{
fontSize: "0.6em",
color: "#888",
marginLeft: "8px",
}}
>
(+{permissionQueue.length - 1} queued)
</span>
)}
</h2> </h2>
<p <p
style={{ style={{
@@ -1012,11 +1026,11 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
> >
The agent wants to use the{" "} The agent wants to use the{" "}
<strong style={{ color: "#ececec" }}> <strong style={{ color: "#ececec" }}>
{permissionRequest.toolName} {permissionQueue[0].toolName}
</strong>{" "} </strong>{" "}
tool. Do you approve? tool. Do you approve?
</p> </p>
{Object.keys(permissionRequest.toolInput).length > 0 && ( {Object.keys(permissionQueue[0].toolInput).length > 0 && (
<pre <pre
style={{ style={{
background: "#1a1a1a", background: "#1a1a1a",
@@ -1032,7 +1046,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
wordBreak: "break-word", wordBreak: "break-word",
}} }}
> >
{JSON.stringify(permissionRequest.toolInput, null, 2)} {JSON.stringify(permissionQueue[0].toolInput, null, 2)}
</pre> </pre>
)} )}
<div <div

View File

@@ -1812,7 +1812,6 @@ async fn tool_prompt_permission(args: &Value, ctx: &AppContext) -> Result<String
let request_id = uuid::Uuid::new_v4().to_string(); let request_id = uuid::Uuid::new_v4().to_string();
let (response_tx, response_rx) = tokio::sync::oneshot::channel(); let (response_tx, response_rx) = tokio::sync::oneshot::channel();
let tool_input_copy = tool_input.clone();
ctx.perm_tx ctx.perm_tx
.send(crate::http::context::PermissionForward { .send(crate::http::context::PermissionForward {
request_id: request_id.clone(), request_id: request_id.clone(),
@@ -1835,7 +1834,11 @@ async fn tool_prompt_permission(args: &Value, ctx: &AppContext) -> Result<String
.map_err(|_| "Permission response channel closed unexpectedly".to_string())?; .map_err(|_| "Permission response channel closed unexpectedly".to_string())?;
if approved { if approved {
Ok(json!({"updatedInput": tool_input_copy}).to_string()) // Claude Code SDK validates the response as a union:
// { behavior: "allow" } | { behavior: "deny", message: string }
// Previously we returned {"updatedInput": ...} which didn't match
// either variant, causing intermittent `invalid_union` errors.
Ok(json!({"behavior": "allow"}).to_string())
} else { } else {
slog_warn!("[permission] User denied permission for '{tool_name}'"); slog_warn!("[permission] User denied permission for '{tool_name}'");
Ok(json!({ Ok(json!({
@@ -3371,7 +3374,7 @@ stage = "coder"
} }
#[tokio::test] #[tokio::test]
async fn tool_prompt_permission_approved_returns_updated_input_json() { async fn tool_prompt_permission_approved_returns_allow_behavior() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path()); let ctx = test_ctx(tmp.path());
@@ -3393,8 +3396,8 @@ stage = "coder"
let parsed: Value = serde_json::from_str(&result).expect("result should be valid JSON"); let parsed: Value = serde_json::from_str(&result).expect("result should be valid JSON");
assert_eq!( assert_eq!(
parsed["updatedInput"]["command"], "echo hello", parsed["behavior"], "allow",
"approved must return updatedInput with original tool input" "approved must return behavior:allow for Claude Code SDK compatibility"
); );
} }