Skip to content

client.resumeSession() returns unresponsive session #540

@revodavid

Description

@revodavid

I am trying to use the equivalent of copilot --continue in the CLI to give a conversational agent continuity of context between sessions. copilot made an implementation but it didn't work because the SDK wasn't working as expected. I asked copilot to make a bug report with a reproducible example, which I include below:

Summary

CopilotClient.resumeSession() from @github/copilot-sdk resolves successfully but the returned session object does not process new messages. Calling session.send() on a resumed session completes without error, but no assistant.message_delta events are ever emitted. The session.idle event fires repeatedly (replayed from prior turn state), but no new response is generated.

Details

CopilotClient.resumeSession() resolves successfully but the returned session object does not process new messages. Calling session.send() on a resumed session completes without error, but no assistant.message_delta events are ever emitted. The session.idle event fires repeatedly (apparently replayed from the prior turn's state), but no new response is generated.

Environment

  • SDK: @github/copilot-sdk ^0.1.25
  • Node.js: 22.x+ (required for node:sqlite)
  • Model: claude-sonnet-4 (also tested with default model)
  • Session config: streaming: true, infiniteSessions: { enabled: true }

Steps to Reproduce

Minimal reproduction script

See resume-session-repro.ts below for a self-contained script. Run with:

npx tsx bug-reports/resume-session-repro.ts

Manual steps

  1. Create a CopilotClient and call client.start().
  2. Create a session with client.createSession({ model: "claude-sonnet-4", streaming: true }).
  3. Send a message with session.send({ prompt: "Hello" }). Observe that assistant.message_delta events fire and a response is received. ✅
  4. Save the session.sessionId.
  5. Call client.resumeSession(sessionId). It resolves successfully. ✅
  6. Subscribe to events on the resumed session and call session.send({ prompt: "Hello again" }).
  7. Observe: no assistant.message_delta events fire. ❌
  8. Observe: session.idle fires immediately (or repeatedly), with no preceding delta events. ❌

Expected Behavior

After resumeSession(), the session should be fully functional. session.send() should produce assistant.message_delta events followed by assistant.message and session.idle, exactly as on a fresh session.

Actual Behavior

  • resumeSession() resolves without error
  • session.send() returns a promise that does not reject
  • session.idle fires prematurely — before any response events
  • assistant.message_delta is never emitted
  • assistant.message may fire with stale content from the prior turn (replayed)
  • tool.execution_start may also fire with stale data from the prior turn
  • The session is effectively dead; no new LLM inference occurs

Impact

This makes resumeSession() unusable for maintaining conversation continuity across reconnects. In our application (a web chat UI), users who disconnect and reconnect see no output from the agent until the system times out and falls back to a new session.

Observed Server Logs

[Web] Attempting to resume session 29f33291-2b36-4c70-b640-ec70f0fea4a4 for David
[Web] Resumed session 29f33291-2b36-4c70-b640-ec70f0fea4a4 for David
[Core] Ignoring premature session.idle (no response events received yet)
[Core] Ignoring premature session.idle (no response events received yet)
[Web] Resume attempt 1 failed for David: Resume attempt timeout
[Web] Retry 1/2 for resumed session (David)
[Core] Ignoring premature session.idle (no response events received yet)
[Core] Ignoring premature session.idle (no response events received yet)
[Core] Ignoring premature session.idle (no response events received yet)
[Core] Ignoring premature session.idle (no response events received yet)
[Web] Resume attempt 2 failed for David: Resume attempt timeout
[Web] Retry 2/2 for resumed session (David)
[Core] Ignoring premature session.idle (no response events received yet)
[Core] Ignoring premature session.idle (no response events received yet)
[Core] Ignoring premature session.idle (no response events received yet)
[Core] Ignoring premature session.idle (no response events received yet)
[Core] Ignoring premature session.idle (no response events received yet)
[Core] Ignoring premature session.idle (no response events received yet)
[Web] Resume attempt 3 failed for David: Resume attempt timeout
[Web] Resumed session unresponsive after 3 attempts for David, falling back to new session

Note: session.idle fires multiple times per attempt with no message_delta events in between. The resumed session is completely unresponsive to new prompts.

Impact

Session resume is currently disabled in Descartes conversational agent. Every reconnect creates a new session, losing conversation history.

Repro file: resume-session-repro.ts (upload failed so pasting here)

/**
 * Reproduction script for @github/copilot-sdk resumeSession bug.
 *
 * Demonstrates that a session resumed via client.resumeSession() does not
 * process new messages: session.send() completes but no assistant.message_delta
 * events fire and session.idle fires prematurely (without a new response).
 *
 * Usage:
 *   npx tsx bug-reports/resume-session-repro.ts
 *
 * Requirements:
 *   - Node.js >= 22.x (SDK requires node:sqlite)
 *   - @github/copilot-sdk ^0.1.25
 *   - Authenticated GitHub Copilot token (via `copilot auth login` or env)
 */

import { CopilotClient, CopilotSession } from "@github/copilot-sdk";

const TIMEOUT_MS = 20000;

async function sendAndCollect(
  session: CopilotSession,
  prompt: string,
  label: string
): Promise<string> {
  return new Promise((resolve, reject) => {
    let content = "";
    let gotDelta = false;

    const unsubs: (() => void)[] = [];

    const cleanup = () => unsubs.forEach((fn) => fn());

    unsubs.push(
      session.on("assistant.message_delta", (event) => {
        gotDelta = true;
        if (event.data.deltaContent) {
          content += event.data.deltaContent;
          process.stdout.write(".");
        }
      })
    );

    unsubs.push(
      session.on("assistant.message", (event) => {
        if (event.data.content) {
          content = event.data.content;
        }
      })
    );

    unsubs.push(
      session.on("session.idle", () => {
        console.log(
          `\n[${label}] session.idle fired — gotDelta: ${gotDelta}, content length: ${content.length}`
        );
        if (!gotDelta) {
          console.log(`[${label}] ⚠️  IDLE WITHOUT ANY DELTAS — session is unresponsive`);
          // Don't resolve yet; let timeout handle it
          return;
        }
        cleanup();
        resolve(content);
      })
    );

    unsubs.push(
      session.on("session.error", (event) => {
        cleanup();
        reject(new Error(`[${label}] Session error: ${event.data.message}`));
      })
    );

    const timer = setTimeout(() => {
      cleanup();
      if (gotDelta) {
        resolve(content);
      } else {
        reject(new Error(`[${label}] Timed out after ${TIMEOUT_MS}ms with no response`));
      }
    }, TIMEOUT_MS);

    unsubs.push(() => clearTimeout(timer));

    console.log(`[${label}] Sending: "${prompt}"`);
    session.send({ prompt }).catch(reject);
  });
}

async function main() {
  console.log("=== @github/copilot-sdk resumeSession Bug Reproduction ===\n");

  // Step 1: Create a client
  console.log("[1] Creating CopilotClient...");
  const client = new CopilotClient();
  await client.start();
  console.log("[1] Client started\n");

  // Step 2: Create a fresh session
  console.log("[2] Creating session...");
  const session = await client.createSession({
    model: "claude-sonnet-4",
    streaming: true,
  });
  const sessionId = session.sessionId;
  console.log(`[2] Session created: ${sessionId}\n`);

  // Step 3: Send a message on the fresh session — this should work
  console.log("[3] Sending message on fresh session...");
  try {
    const response1 = await sendAndCollect(
      session,
      "Say exactly: Hello, session works!",
      "FRESH"
    );
    console.log(`[3] ✅ Fresh session response (${response1.length} chars): "${response1.slice(0, 100)}"\n`);
  } catch (err) {
    console.log(`[3] ❌ Fresh session failed: ${(err as Error).message}\n`);
    process.exit(1);
  }

  // Step 4: Resume the same session
  console.log("[4] Resuming session...");
  let resumed: CopilotSession;
  try {
    resumed = await client.resumeSession(sessionId);
    console.log(`[4] Session resumed: ${resumed.sessionId}\n`);
  } catch (err) {
    console.log(`[4] ❌ Resume failed: ${(err as Error).message}`);
    console.log("    (This may happen if the session expired — try running again quickly)\n");
    process.exit(1);
  }

  // Step 5: Send a message on the resumed session — this is expected to fail
  console.log("[5] Sending message on resumed session...");
  try {
    const response2 = await sendAndCollect(
      resumed,
      "Say exactly: Hello, resume works!",
      "RESUMED"
    );
    console.log(`[5] ✅ Resumed session response (${response2.length} chars): "${response2.slice(0, 100)}"\n`);
    console.log("=== RESULT: resumeSession is WORKING ===");
  } catch (err) {
    console.log(`[5] ❌ Resumed session failed: ${(err as Error).message}\n`);
    console.log("=== RESULT: resumeSession is BROKEN ===");
    console.log("The resumed session accepted send() but produced no message_delta events.");
    console.log("session.idle fires prematurely (replayed from prior turn state).");
  }

  process.exit(0);
}

main().catch((err) => {
  console.error("Fatal error:", err);
  process.exit(1);
});

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions