github / github/copilot-sdk

session.rpc.ui.elicitation() returns instant decline (1-2ms) without showing modal after sendAndWait

Open
#1,149 2 comments 0 reactions 0 assignees View on GitHub
documentation
Dominant language
Java
Stars
10.5k
Forks
1.5k
Avg merge
1d 11h
Merged PRs (30d)
128

Description

## Environment

- **Copilot CLI version:** 1.0.36
- **Extension SDK:** `@github/copilot-sdk/extension` (`joinSession` API)
- **OS:** Windows 11 (Windows_NT)

## Summary

When a Copilot CLI extension calls `session.rpc.ui.elicitation()` from inside a command handler that previously called `session.sendAndWait()`, the elicitation resolves with `{ action: "decline" }` in 1-2ms without ever presenting a modal to the user. The session reports `capabilities.ui.elicitation = true` and `mode = interactive`, yet the user never sees any prompt.

The extension's agent-mediated fallback (sending a prompt asking the model to call `ask_user`) also fails because the `ask_user` tool auto-declines with "The user is not available to respond" for turns injected by `sendAndWait`.

**Net result:** The extension cannot collect any human input after delegating work via `sendAndWait`, making interactive gates (review finding selection, publish attestation) impossible.

## Steps to reproduce

1. Load a Copilot CLI extension that registers a command via `joinSession({ commands: [...] })`
2. In the command handler, call `session.sendAndWait()` to delegate to another command
3. After `sendAndWait` resolves, call `session.rpc.ui.elicitation()` with a valid schema
4. Observe that it resolves instantly with `{ action: "decline" }` --- no UI is shown

### Minimal reproduction

```js
import { joinSession } from "@github/copilot-sdk/extension";

const session = await joinSession({
commands: [
{
name: "test-elicit",
description: "Test elicitation after sendAndWait",
handler: async (context) => {
// Step 1: Delegate work to the model
await session.sendAndWait({ prompt: "Say hello" }, 60_000);

// Step 2: Try to elicit user input
const start = Date.now();
const result = await session.rpc.ui.elicitation({
message: "Pick an option",
requestedSchema: {
type: "object",
properties: {
choice: {
type: "string",
title: "Your choice",
enum: ["A", "B", "C"],
default: "A",
},
},
required: ["choice"],
},
});
const elapsed = Date.now() - start;

// Logs: "Elicitation returned decline in 1ms"
await session.log(
Elicitation returned {result.action} in {elapsed}ms
);
},
},
],
});
```

## Observed behavior

### Timeline from session events

```
T+0s session.info "interactive elicitation: available; session mode: unknown"
<- session.capabilities.ui.elicitation reports true

T+997s sendAndWait("/review ...") called, completes successfully

T+997s session.rpc.mode.set({ mode: "interactive" }) succeeds
<- mode is now "interactive"

T+997s session.rpc.ui.elicitation() called
<- returns { action: "decline" } in 1ms
<- NO MODAL IS SHOWN TO THE USER

T+997s Agent-mediated fallback: sendAndWait() with prompt asking model to call ask_user
<- model calls ask_user
<- ask_user returns: "The user is not available to respond"
<- model returns {"action":"decline"}

T+1024s Second elicitation attempt (text fallback)
<- session.rpc.ui.elicitation() returns decline in 2ms (again, no modal)
<- agent-mediated fallback also fails with "user not available"
```

### Raw session event evidence

```json
{
"type": "session.warning",
"data": {
"message": "Direct elicitation returned decline in 1ms (mode: interactive); treating as synthetic decline."
},
"timestamp": "2026-04-27T03:00:07.856Z"
}

{
"type": "tool.execution_complete",
"data": {
"toolTelemetry": {
"properties": {
"elicitation_action": "decline",
"elicitation_field_types": "[\"multi-enum\",\"string\"]"
}
},
"result": {
"content": "The user is not available to respond and will review your work later."
}
},
"timestamp": "2026-04-27T03:00:31.300Z"
}
```

## Expected behavior

`session.rpc.ui.elicitation()` should present an interactive modal to the user and block until the user accepts, declines, or cancels. If the host cannot present a modal in the current execution context (e.g., inside a `sendAndWait` callback), it should either:

1. **Queue the elicitation** and present it after the current turn completes, or
2. **Throw an error** with a clear message like `"Elicitation not supported during sendAndWait callbacks"`, or
3. **Return a distinct result** (e.g., `{ action: "unavailable" }`) so extensions can distinguish "host can't show UI" from "user clicked decline"

Additionally, `ask_user` should work for model turns injected by extensions via `sendAndWait`. The user IS present --- they typed a command and are waiting. The `sendAndWait` turn is extension-initiated, but the session is still interactive.

## Call context where elicitation fails

```
User types: /submit-pr
-> handleSubmitPr()
-> runPreflight() <- works
-> sendAndWait("/review ...") <- works (30min timeout)
-> recordAndLogReviewOutput() <- works
-> askForReviewDecisions() <- FAILS HERE
-> elicit(message, schema)
-> session.rpc.ui.elicitation() <- returns decline in 1ms
-> agentMediatedElicitation()
-> sendAndWait(prompt) <- model calls ask_user
-> ask_user <- "user not available"
```

## Extension workaround code (currently failing)

```js
async function elicit(message, requestedSchema) {
if (!session.capabilities?.ui?.elicitation) {
return agentMediatedElicitation(message, requestedSchema, "unavailable");
}

const mode = await ensureInteractiveMode(message);
if (mode === "autopilot") {
return agentMediatedElicitation(message, requestedSchema, "autopilot");
}

const elicitStart = Date.now();
const result = await session.rpc.ui.elicitation({ message, requestedSchema });
const elicitMs = Date.now() - elicitStart;

// Detect synthetic decline (returned too fast for human interaction)
const isSyntheticDecline =
result.action === "decline" &&
(["autopilot", "unknown"].includes(await refreshSessionMode())
|| elicitMs < 2000);

if (isSyntheticDecline) {
return agentMediatedElicitation(message, requestedSchema,
licitation-returned-in-{elicitMs}ms);
}

return result;
}

async function agentMediatedElicitation(message, requestedSchema, reason) {
// Asks the model to call ask_user on behalf of the extension
// FAILS: ask_user auto-declines for sendAndWait-injected turns
const response = await sendAndCaptureContent(
agentElicitationPrompt(message, requestedSchema, reason),
15 * 60 * 1000
);
// ...parse JSON response...
}
```

Both layers fail:
- **Layer 1** fails because `session.rpc.ui.elicitation()` returns `decline` in 1ms
- **Layer 2** fails because `ask_user` auto-declines for turns injected by `sendAndWait`

## Impact

- Extensions cannot implement interactive multi-step workflows
- `session.capabilities.ui.elicitation` capability check is misleading --- reports `true` but doesn't work in practice
- Extensions requiring human-in-the-loop gates (security attestation, publish confirmation) cannot enforce them
- The `sendAndWait` -> `elicit` pattern is the most natural way to build multi-step extension workflows, and it's completely broken

Contributor guide

Open the contributing guide

Research direction

Start at the joinSession command handler and trace the session.sendAndWait() and session.rpc.ui.elicitation() paths, including the ask_user fallback. Reproduce the instant decline on Windows with the minimal example and inspect the session events. Done means interactive elicitation can collect input after sendAndWait, or the extension receives a distinct, actionable unavailable result.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, node.js
Domain
api, developer-experience
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.