Skip to content

docs: streaming-events.md marks four persisted events as "Ephemeral" #2171

Description

@examon

docs/features/streaming-events.md is the field-level reference for session events. It labels four events as "Ephemeral.", but all four are persisted to the session event log and are still present in a resumed session's history.

The file defines that word itself, so the label is a falsifiable claim rather than a loose adjective:

| Ephemeral event | Transient; streamed in real time but not persisted to the session log. Not replayed on session resume. |
| Persisted event | Saved to the session event log on disk. Replayed when resuming a session. |

The four affected events are permission.requested, permission.completed, external_tool.requested and external_tool.completed.

Why it is wrong

The same file already gives the operational rule, in the event-envelope table:

| ephemeral | boolean? | true for transient events; absent or false for persisted events |

In the sessions I ran, none of the four carried the field: ephemeral is absent both on the wire and in the event log, which by the rule above means persisted.

The SDK's own generated types say the same thing. A genuinely ephemeral event declares the field as a required literal:

export interface IdleEvent {
  /** Always true for events that are transient and not persisted to the session event log on disk. */
  ephemeral: true;

The four events in question do not. Three of them declare the ordinary optional boolean, the shape used by every other persisted event, identical to assistant.turn_start:

export interface PermissionRequestedEvent {
  ephemeral?: boolean;
// PermissionCompletedEvent, ExternalToolRequestedEvent: same

ExternalToolCompletedEvent declares an optional literal, which is the only such shape in the generated types:

export interface ExternalToolCompletedEvent {
  ephemeral?: true;

An optional literal constrains the value only when the field is present. It does not assert that the event is ephemeral, and the field is absent on the events current sessions emit.

How it drifted

The labels were accurate when the guide was added in #717 (2026-03-07): at that time all four events declared a required ephemeral: true.

#1177 regenerated the event types, and all four moved off that shape in a single commit:

Event Before #1177 After #1177
permission.requested ephemeral: true ephemeral?: boolean
permission.completed ephemeral: true ephemeral?: boolean
external_tool.requested ephemeral: true ephemeral?: boolean
external_tool.completed ephemeral: true ephemeral?: true
session.idle (control) ephemeral: true ephemeral: true

The prose was not updated with them, so the guide has described these four events as transient since then. The persistence is deliberate: it is what lets a pending permission or a pending external tool call survive a disconnect and be picked up again on resume.

Reproducing

mkdir repro && cd repro
npm init -y >/dev/null
npm install @github/copilot-sdk

repro.mjs:

import { CopilotClient } from "@github/copilot-sdk";
import { readFileSync, existsSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

const work = join(process.cwd(), "work");
mkdirSync(work, { recursive: true });

function typesOnDisk(sessionId) {
  const p = join(homedir(), ".copilot", "session-state", sessionId, "events.jsonl");
  const counts = {};
  if (!existsSync(p)) return counts;
  for (const line of readFileSync(p, "utf8").split("\n")) {
    if (!line.trim()) continue;
    try {
      const ev = JSON.parse(line);
      counts[ev.type] = (counts[ev.type] || 0) + 1;
    } catch {}
  }
  return counts;
}

const client = new CopilotClient({ workingDirectory: work });
await client.start();

const session = await client.createSession({
  workingDirectory: work,
  onPermissionRequest: () => ({ kind: "approve-once" }),
});
const sessionId = session.sessionId;

await session.sendAndWait({
  prompt:
    "Create a file named note.txt in the current directory containing exactly the text HELLO. " +
    "Use your file-writing tool. Do not ask me anything; just do it.",
}, 90_000);

const onDisk = typesOnDisk(sessionId);
console.log("on disk  permission.requested:", onDisk["permission.requested"] || 0);
console.log("on disk  permission.completed:", onDisk["permission.completed"] || 0);
console.log("on disk  session.idle (control, genuinely ephemeral):", onDisk["session.idle"] || 0);
await session.disconnect();

const resumed = await client.resumeSession(sessionId, {
  onPermissionRequest: () => ({ kind: "approve-once" }),
});
const after = {};
for (const e of await resumed.getEvents()) after[e.type] = (after[e.type] || 0) + 1;
console.log("after resume  permission.requested:", after["permission.requested"] || 0);
console.log("after resume  permission.completed:", after["permission.completed"] || 0);
console.log("after resume  session.idle (control, expect 0):", after["session.idle"] || 0);
await resumed.disconnect();
await client.stop();
node repro.mjs

Expected, if the documentation were accurate: the permission events are transient, so they do not appear in the event log and are gone after resume, exactly like the session.idle control.

Actual: they are in the event log and still in the resumed session's history. Only the control behaves as documented.

on disk  permission.requested: 2
on disk  permission.completed: 2
on disk  session.idle (control, genuinely ephemeral): 0
after resume  permission.requested: 2
after resume  permission.completed: 2
after resume  session.idle (control, expect 0): 0

I observed this with the SDK built from main; the snippet above is the equivalent against the published package.

Substituting a declaration-only tool, as in nodejs/samples/manual-tool-resume.ts, shows the same result for external_tool.requested and external_tool.completed.

Impact

Someone building on the documented contract will skip these events when scanning a session's event log, or write redundant state reconstruction for pending permissions and pending external tool calls after a resume, because the guide says the records will not be there. These are exactly the events that resume continuity depends on.

Affected locations

All in docs/features/streaming-events.md: the four event sections, the two permission.* entries of the "agentic turn flow" diagram, and the four corresponding rows of the "All event types at a glance" table.

The other 20 events marked "Ephemeral." in that file are correct.

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentation

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions