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();
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.
docs/features/streaming-events.mdis 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:
The four affected events are
permission.requested,permission.completed,external_tool.requestedandexternal_tool.completed.Why it is wrong
The same file already gives the operational rule, in the event-envelope table:
In the sessions I ran, none of the four carried the field:
ephemeralis 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:
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:ExternalToolCompletedEventdeclares an optional literal, which is the only such shape in the generated types: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:
permission.requestedephemeral: trueephemeral?: booleanpermission.completedephemeral: trueephemeral?: booleanexternal_tool.requestedephemeral: trueephemeral?: booleanexternal_tool.completedephemeral: trueephemeral?: truesession.idle(control)ephemeral: trueephemeral: trueThe 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
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.idlecontrol.Actual: they are in the event log and still in the resumed session's history. Only the control behaves as documented.
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 forexternal_tool.requestedandexternal_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 twopermission.*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.