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
John-Rood commentedon Oct 23, 2026
Separate two questions here: why the context is gone, and which of your three options the runtime can actually ship.
Why it is gone. The runtime delivers
sessionStartadditionalContextas its own user message directly after the system message, which your first post-compaction request shows plainly. Compaction then rebuilds the message list from the summary plus a token-bounded tail, and that message is neither system content nor something the summarizer has to preserve. It drops by construction. So option 1 is not just "keep the message": it needs a rule for which messages are exempt from the tail budget, and you would still be racing the summarizer folding the text into the summary.Option 2 is the smallest change. The resume path already runs
sessionStartand already injects its output as that same message, so acompactsource reuses an existing injection path with one more enum value on the compaction branch. The data is at the call site:preCompactalready receivestrigger("manual" or "auto") andtranscriptPath, so the handler that decides a compaction is happening is the same one that could raise the second event.Option 3 is the largest. In 1.0.103
preCompactis wired notification-only and there is no post-compaction event at all, soadditionalContextfrom apostCompacthook is a new delivery path rather than a new field.Until option 2 lands, the only content that survives compaction is what sits inside the system message, which your table already proves for
copilot-instructions.md. That gives a workaround that does not ask users to edit a personal file: the plugin owns a managed block in that file and rewrites it from its own source, so the copy cannot go stale.Three hooks, one file. In
$COPILOT_HOME/hooks/rules-digest.json(default~/.copilot/hooks/):{ "version": 1, "hooks": { "sessionStart": [ { "type": "command", "command": "node /path/to/rules-digest.mjs sessionStart", "env": { "RULES_SRC": "/path/to/plugin/rules" } } ], "preCompact": [ { "type": "command", "command": "node /path/to/rules-digest.mjs preCompact", "env": { "RULES_SRC": "/path/to/plugin/rules" } } ], "postToolUse": [ { "type": "command", "command": "node /path/to/rules-digest.mjs postToolUse", "env": { "RULES_SRC": "/path/to/plugin/rules" } } ] } }rules-digest.mjs:What each one buys you:
sessionStartwrites the block, so a session that later compacts starts with the rules in the system message. No output to the model, no duplicate.preCompactwrites it again, for the next session. The system message for the running session is already built, so this one is preparation, not rescue.postToolUsecovers the cases the block cannot: the system message being rebuilt on a model switch, or the block being stale when the session started.preCompactleaves a per-session flag, and the first tool call after the compaction appends the current digest to that tool result and clears the flag. One injection per compaction, not one per turn, so history does not grow.Tested here against mock payloads, not against a live session: the managed block is written with the user's own text outside the markers preserved, a second run is a no-op, a digest over budget truncates at a line boundary, the flag is per
sessionIdand consumed by exactly onepostToolUse, and a missingRULES_SRCor malformed stdin returns{}with exit 0 so the hook cannot break a session. The claim that the instructions file survives compaction is your measurement, not a second one from me.Two limits worth writing down.
postToolUseadditionalContextis appended to the tool result and capped at 10 KB, which is why the budget defaults to 6000 bytes. And a plugin writing intocopilot-instructions.mdneeds the markers, or it will eventually eat a user's hand-written file.If the behavior stays as it is, the docs note you asked for is the minimum. The hooks reference already says
preCompactoutput is not processed, so thesessionStartrow is the one missing a caveat about compaction.The other half of this is the history the rules are applied to. After a compaction the summary keeps the shape of the work, not the specifics, and a new session starts with none of it. MemoryRouter keeps the conversation itself across sessions and survives compaction, and it runs as an MCP server, so the CLI can load it next to your existing servers: https://docs.memoryrouter.ai/mcp?utm_source=github&utm_medium=issue&utm_campaign=github-copilot-cli