Skip to content

Keep sessionStart hook additionalContext after compaction #5225

Description

@naoki1213mj

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:

  1. When compaction replaces the history, keep the message that carries the sessionStart context, as the runtime already keeps the system message.
  2. Run sessionStart again after compaction with source: "compact" and inject its additionalContext. Claude Code does this, so plugins that register SessionStart with a compact matcher would work on Copilot unchanged.
  3. Add a postCompact hook whose additionalContext reaches 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

  1. 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.
  2. A sessionStart hook injects the models the user approved for subagents, and a preToolUse hook denies any other model. After compaction the agent no longer sees the list, picks another model, and the hook denies the call.
  3. 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
  • sessionStart ran with source: "new" and then source: "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 onSessionStart callback 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

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

  1. added
    area:context-memoryContext window, memory, compaction, checkpoints, and instruction loading
    area:pluginsPlugin system, marketplace, hooks, skills, extensions, and custom agents
    and removed on Oct 22, 2026
  2. John-Rood commented on Oct 23, 2026

    @John-Rood

    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 sessionStart additionalContext as 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 sessionStart and already injects its output as that same message, so a compact source reuses an existing injection path with one more enum value on the compaction branch. The data is at the call site: preCompact already receives trigger ("manual" or "auto") and transcriptPath, 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 preCompact is wired notification-only and there is no post-compaction event at all, so additionalContext from a postCompact hook 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:

    #!/usr/bin/env node
    // rules-digest.mjs: keep plugin rules in the system message, re-inject once after compaction.
    import fs from 'node:fs';
    import os from 'node:os';
    import path from 'node:path';
    
    const mode = process.argv[2];
    const home = process.env.COPILOT_HOME || path.join(os.homedir(), '.copilot');
    const target = path.join(home, 'copilot-instructions.md');
    const BEGIN = '<!-- rules-digest:begin -->';
    const END = '<!-- rules-digest:end -->';
    const max = Number(process.env.RULES_MAX || 6000);
    
    let input = {};
    try { input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); } catch {}
    
    function digest() {
      const src = process.env.RULES_SRC || '';
      let text = '';
      try {
        text = fs.statSync(src).isDirectory()
          ? fs.readdirSync(src).filter(f => f.endsWith('.md')).sort()
              .map(f => fs.readFileSync(path.join(src, f), 'utf8')).join('\n\n')
          : fs.readFileSync(src, 'utf8');
      } catch { return ''; }
      text = text.trim();
      const bytes = Buffer.from(text);
      if (bytes.length <= max) return text;
      return bytes.subarray(0, max).toString('utf8').replace(/\n[^\n]*$/, '') +
        `\n\n[digest truncated at ${max} bytes]`;
    }
    
    function sync() {
      const d = digest();
      if (!d) return;
      let cur = '';
      try { cur = fs.readFileSync(target, 'utf8'); } catch {}
      const start = cur.indexOf(BEGIN), stop = cur.indexOf(END);
      const head = start >= 0 ? cur.slice(0, start) : (cur ? cur.trimEnd() + '\n\n' : '');
      const tail = stop > start ? cur.slice(stop + END.length) : '\n';
      const next = `${head}${BEGIN}\n${d}\n${END}${tail}`;
      if (next === cur) return;
      fs.mkdirSync(path.dirname(target), { recursive: true });
      fs.writeFileSync(target + '.tmp', next);
      fs.renameSync(target + '.tmp', target);
    }
    
    const flagPath = path.join(home, 'hooks-state',
      String(input.sessionId || 'session').replace(/[^\w.-]/g, '_') + '.compact');
    
    if (mode === 'sessionStart') {
      sync();
      process.stdout.write('{}');
    } else if (mode === 'preCompact') {
      sync();
      const d = digest();
      if (d) {
        fs.mkdirSync(path.dirname(flagPath), { recursive: true });
        fs.writeFileSync(flagPath, JSON.stringify({ digest: d, trigger: input.trigger || null }));
      }
      process.stdout.write('{}');
    } else if (mode === 'postToolUse') {
      let pending = null;
      try { pending = JSON.parse(fs.readFileSync(flagPath, 'utf8')); } catch {}
      if (!pending) process.stdout.write('{}');
      else {
        fs.rmSync(flagPath, { force: true });
        process.stdout.write(JSON.stringify({
          additionalContext: 'Your session rules were replaced by the last compaction. '
            + 'They are reproduced here once so they hold for the rest of the session.\n\n' + pending.digest,
        }));
      }
    } else {
      process.stderr.write('usage: rules-digest.mjs sessionStart|preCompact|postToolUse\n');
      process.exit(2);
    }

    What each one buys you:

    • sessionStart writes the block, so a session that later compacts starts with the rules in the system message. No output to the model, no duplicate.
    • preCompact writes it again, for the next session. The system message for the running session is already built, so this one is preparation, not rescue.
    • postToolUse covers the cases the block cannot: the system message being rebuilt on a model switch, or the block being stale when the session started. preCompact leaves 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 sessionId and consumed by exactly one postToolUse, and a missing RULES_SRC or 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. postToolUse additionalContext is appended to the tool result and capped at 10 KB, which is why the budget defaults to 6000 bytes. And a plugin writing into copilot-instructions.md needs 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 preCompact output is not processed, so the sessionStart row 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:context-memoryContext window, memory, compaction, checkpoints, and instruction loadingarea:pluginsPlugin system, marketplace, hooks, skills, extensions, and custom agents

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions