Repository navigation
Keep sessionStart hook additionalContext after compaction #5225
Description
Describe the feature or problem you'd like to solve
After compaction, the model no longer receives the additionalContext that sessionStart hooks returned, and nothing restores it until the session is resumed.
Proposed solution
Any one of these would solve it:
- When compaction replaces the history, keep the message that carries the
sessionStartcontext, as the runtime already keeps the system message. - Run
sessionStartagain after compaction withsource: "compact"and inject itsadditionalContext. Claude Code does this, so plugins that registerSessionStartwith acompactmatcher would work on Copilot unchanged. - Add a
postCompacthook whoseadditionalContextreaches the compacted context. Preserve durable context across repeated compactions #4441 proposes something close to this.
If the current behavior stays, please state in the hooks reference that sessionStart context does not survive compaction.
Plugins and repositories use sessionStart to inject rules that should hold for the whole session. Today the rules hold only until the first compaction, and neither the user nor the agent is told that they are gone.
Example prompts or workflows
- A plugin injects routing rules at session start, such as which workflow to load for multi-file changes, and expects them to hold for the whole session. Today, automatic compaction in a long autopilot session drops them, and the agent stops following them.
- A
sessionStarthook injects the models the user approved for subagents, and apreToolUsehook denies any other model. After compaction the agent no longer sees the list, picks another model, and the hook denies the call. - A repository hook injects ticket constraints at session start. After the user runs
/compact, the constraints are gone until the session is resumed.
Additional context
The current behavior matches the hooks reference. It says sessionStart fires when "A new or resumed session begins", lists its source values as "startup", "resume", and "new", and says the runtime does not process preCompact output. So I'm filing this as a feature request, not a bug.
What I measured
I pointed a session at a local mock OpenAI-compatible server through a BYOK provider and recorded which markers each model request contained. A sessionStart hook returned one marker in additionalContext, and copilot-instructions.md in the config directory held another.
| Request | sessionStart context | copilot-instructions.md |
|---|---|---|
| Turn 1 | yes | yes |
| Compaction summary request | yes | yes |
| Turn 2, after compaction | no | yes |
| Turn 3, after resume | yes | yes |
sessionStartran withsource: "new"and thensource: "resume". It did not run for compaction.- The runtime sends the hook context as a separate user message right after the system message. The first request after compaction contains only the system message, the summary message, and the new prompt.
- A plugin command hook and an SDK
onSessionStartcallback behaved the same. So did manual compaction and threshold-triggered background compaction. - Runtime 1.0.99-2 and 1.0.100-1 on macOS arm64 gave the same result. The release notes through 1.0.103-0 don't mention a change.
- The summarizer may paraphrase the hook text into the summary, but the original text is gone.
The GitHub Copilot app shows the same pattern in normal use. The app shuts a session down about 10 minutes after the last turn and resumes it on the next message. That resume is the only point where sessionStart runs again, so after each compaction the context stays missing until the next idle resume.
Both workarounds I know of have costs. Copying the rules into ~/.copilot/copilot-instructions.md works because that file stays in the system message, but every user has to edit a personal file, and the copy goes stale when the plugin changes. A userPromptTransformed hook could append the rules to every prompt, but the docs say the replaced content is stored in session history, so the rules would repeat on every turn. I haven't tried this one.
References, checked 2026-10-22
- Copilot hooks reference: https://docs.github.com/en/copilot/reference/hooks-reference
- Claude Code hooks reference, where
SessionStartsourceincludescompact: https://code.claude.com/docs/en/hooks - Related issues: Preserve durable context across repeated compactions #4441, Repo-level instruction files (AGENTS.md/CLAUDE.md/etc.) are not preserved across /compact #4687
- Feature Request: Add preCompact hook event for context refresh before token compaction #1138 asked for
preCompactso hooks could refresh context before compaction. It was closed whenpreCompactshipped, but the runtime does not processpreCompactoutput, so that use case is still not covered.
Repro script
Requires Node.js. Set COPILOT_SDK to the SDK's index.js. I used the copy bundled with the GitHub Copilot app. Set COPILOT_RUNTIME to a copilot runtime binary or to the index.js of the @github/copilot package. The session uses a local mock model server and a temporary config directory, and the script deletes the directory at the end.
// Shows whether sessionStart additionalContext reaches the model after compaction.
//
// Usage:
// COPILOT_SDK=/path/to/copilot-sdk/index.js \
// COPILOT_RUNTIME=/path/to/copilot \
// node repro-sessionstart-compaction.mjs
//
// COPILOT_RUNTIME is a runtime binary or the index.js of the @github/copilot package.
// The session talks to a local mock model server, so the script makes no real model calls.
// It writes only to a temporary COPILOT_HOME, which it deletes at the end.
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
const { COPILOT_SDK, COPILOT_RUNTIME } = process.env;
if (!COPILOT_SDK || !COPILOT_RUNTIME) {
console.error("Set COPILOT_SDK to the SDK's index.js and COPILOT_RUNTIME to the copilot runtime.");
process.exit(2);
}
const { CopilotClient, RuntimeConnection, approveAll } = await import(pathToFileURL(COPILOT_SDK).href);
const HOOK_MARKER = "HOOK-MARKER-7Q2";
const INSTRUCTIONS_MARKER = "INSTRUCTIONS-MARKER-4K9";
const SUMMARY = [
"<overview>A short test conversation.</overview>",
"<history>1. The user sent a test message.</history>",
"<work_done>None.</work_done>",
"<technical_details>None.</technical_details>",
"<important_files>None.</important_files>",
"<next_steps>Wait for the next message.</next_steps>",
"<checkpoint_title>Test checkpoint</checkpoint_title>",
].join("\n");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "sessionstart-compaction-"));
const home = path.join(tmp, "home");
const work = path.join(tmp, "work");
fs.mkdirSync(home);
fs.mkdirSync(work);
fs.writeFileSync(path.join(home, "copilot-instructions.md"), `${INSTRUCTIONS_MARKER}\n`);
// A mock OpenAI-compatible server that records which markers each chat request contains.
const requests = [];
let step = "setup";
const server = http.createServer((req, res) => {
let raw = "";
req.on("data", (chunk) => (raw += chunk));
req.on("end", () => {
if (req.method !== "POST") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ object: "list", data: [{ id: "mock-model", object: "model", owned_by: "mock" }] }));
return;
}
const body = JSON.parse(raw);
const messages = JSON.stringify(body.messages);
const isSummary = messages.includes("Please create a detailed summary");
requests.push({
request: isSummary ? "compaction summary request" : step,
"sessionStart context": messages.includes(HOOK_MARKER) ? "yes" : "no",
"copilot-instructions.md": messages.includes(INSTRUCTIONS_MARKER) ? "yes" : "no",
});
const text = isSummary ? SUMMARY : "OK";
const created = Math.floor(Date.now() / 1000);
const usage = { prompt_tokens: 1000, completion_tokens: 20, total_tokens: 1020 };
if (!body.stream) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ id: "mock", object: "chat.completion", created, model: "mock-model", choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" }], usage }));
return;
}
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
const send = (chunk) => res.write(`data: ${JSON.stringify(chunk)}\n\n`);
send({ id: "mock", object: "chat.completion.chunk", created, model: "mock-model", choices: [{ index: 0, delta: { role: "assistant", content: text }, finish_reason: null }] });
send({ id: "mock", object: "chat.completion.chunk", created, model: "mock-model", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage });
res.end("data: [DONE]\n\n");
});
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address();
const sources = [];
const sessionConfig = {
model: "mock-model",
provider: { type: "openai", wireApi: "completions", baseUrl: `http://127.0.0.1:${port}/v1`, apiKey: "mock", maxPromptTokens: 120000, maxOutputTokens: 4096 },
onPermissionRequest: approveAll,
workingDirectory: work,
hooks: {
onSessionStart: async (input) => {
sources.push(input.source);
return { additionalContext: `${HOOK_MARKER} (source: ${input.source})` };
},
},
};
const newClient = () =>
new CopilotClient({
connection: RuntimeConnection.forStdio({ path: COPILOT_RUNTIME }),
baseDirectory: home,
workingDirectory: work,
logLevel: "error",
});
let client = newClient();
try {
await client.start();
const session = await client.createSession(sessionConfig);
step = "turn 1";
await session.sendAndWait({ prompt: "First message." }, 60000);
step = "manual compaction";
await session.rpc.history.compact({ trigger: "manual" });
step = "turn 2, after compaction";
await session.sendAndWait({ prompt: "Second message." }, 60000);
await session.disconnect();
await client.stop();
client = newClient();
await client.start();
const resumed = await client.resumeSession(session.sessionId, sessionConfig);
step = "turn 3, after resume";
await resumed.sendAndWait({ prompt: "Third message." }, 60000);
await resumed.disconnect();
} finally {
await client.stop().catch(() => {});
server.close();
fs.rmSync(tmp, { recursive: true, force: true });
}
console.log(`sessionStart ran with source: ${sources.join(", ")}`);
console.table(requests);
process.exit(0);
Activity