Skip to content

Commit 58b93fd

Browse files
ShawnXxyCopilot
andcommitted
fix(copilot): preserve copilot-home, usage, and permission evidence
Applies the four review findings on PR #22. The agent-customize CLI parsed --copilot-home but never forwarded it to the collector, so every CLI run inventoried the real ~/.copilot instead of the requested path. That broke the CLI half of CHS-AC-6 while the collector API stayed correct. scripts/agent-customize/cli.mjs now passes copilotHome through, and a CLI regression test asserts the inventory resolves under the fixture home and that no evidence path points at the real user home. The Copilot platform module dropped the outputTokens that assistant messages do record. isModelRequestEvent excludes assistant-typed events, so usage cannot ride the message itself: each assistant message that reports usage now also emits a companion model.response.completed carrying outputTokens, messageId as responseId, and requestId. Input and cache tokens stay absent rather than zero. 10,462 of 11,313 assistant messages across 491 local sessions carry a non-zero outputTokens, so the coverage is genuinely partial and the warning is renamed copilot-per-response-usage-partial. permission.requested and permission.completed now normalize into the shared control.permission lifecycle, retaining only requestId, the request kind, and the result decision; intents, paths, and commands are never retained. The decision rides the result event alone, because permissionObservation counts any event carrying a decision and Copilot emits a request even when policy auto-approves, so decorating both would double-count and label all 1,177 requests as prompted friction. toolInvocationId is deliberately left unset: dedupeEvents keys on it, and 32 toolCallIds repeat across separate permission requests, which would have silently dropped legitimate events. Source coverage now emits the canonical session-core-facts fields that safeSourceCoverage whitelists, keeping the Copilot transcript counters beside them so analysisWarnings keeps working. Two latent bugs surfaced here: coverage read probe fields off session descriptors that never carried them, so withRequest was always zero, and the relevant-set ladder narrowed even without a requested window, hiding transcript-less sessions instead of reporting them as unreadable. CHS-AC-5 and CHS-AC-6 in docs/specs/2026-07-29-copilot-host-support.md now describe partial per-response usage and the payload-free permission lifecycle, and Session Diagnostics documents the two added boundaries. Validated with npm test (874/874), npm run pack:verify (PASS, npm 314 / runtime zip 338), and node --test test/doc-link-graph.test.mjs (6/6, no mermaid drift). Co-authored-by: Copilot App (Claude Opus 5) <223556219+Copilot@users.noreply.github.com>
1 parent 8f362ea commit 58b93fd

6 files changed

Lines changed: 303 additions & 30 deletions

File tree

docs/specs/2026-07-29-copilot-host-support.md

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ Harness report.
2727
Copilot's transcript is the richest of any supported host: `events.jsonl`
2828
records hook, subagent, plan, compaction, and permission lifecycle events
2929
directly. That evidence must be normalized without inventing coverage Copilot
30-
does not record. Copilot does not persist per-response token usage in the
31-
transcript, and VS Code Copilot Chat has no documented durable transcript, so
32-
both stay explicit boundaries rather than zero values.
30+
does not record. Copilot records output tokens per assistant message but no
31+
input tokens, cache tokens, or cost, and VS Code Copilot Chat has no documented
32+
durable transcript, so both stay explicit boundaries rather than zero values.
3333

3434
## Acceptance Scenarios
3535

@@ -51,14 +51,19 @@ both stay explicit boundaries rather than zero values.
5151
`--platform copilot` through a capability-owned platform module that reads
5252
workspace-matching `~/.copilot/session-state/<id>/events.jsonl`, and both the
5353
public and capability-owned analyzer factories resolve it.
54-
- **CHS-AC-5 (evidence boundaries):** Copilot facts report
55-
`usageFieldsObserved: false` because the transcript records no per-response
56-
token usage. Session Diagnostics states that VS Code Copilot Chat has no
57-
supported durable transcript and that `session-store.db` is documented as
58-
auto-managed and is not an evidence source.
54+
- **CHS-AC-5 (evidence boundaries):** Copilot facts carry the per-response
55+
`outputTokens` the transcript records and omit input tokens, cache tokens, and
56+
cost rather than reporting them as zero, so per-response usage coverage is
57+
partial and complete usage still requires the opt-in OpenTelemetry export.
58+
Permission request and result events normalize into the shared permission
59+
lifecycle without retaining prompt intents, paths, or commands. Session
60+
Diagnostics states that VS Code Copilot Chat has no supported durable
61+
transcript and that `session-store.db` is documented as auto-managed and is not
62+
an evidence source.
5963
- **CHS-AC-6 (bundle propagation):** `harness evidence-bundle --platform copilot`
6064
freezes a Copilot context and returns all three lanes, and `--copilot-home`
61-
routes isolated configuration paths into the Agent Customize lane.
65+
routes isolated configuration paths into the Agent Customize lane through both
66+
the collector API and the `agent-customize` CLI.
6267
- **CHS-AC-7 (host routing):** The host adapter matrix carries a Copilot row with
6368
discovery paths, evidence sources, default output, and a smoke command.
6469
Portable HTML routing includes Copilot, and Qoder remains the only Canvas host.

references/session-evidence/sessions-diagnostics.md

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -259,15 +259,23 @@ transcript records are the primary source: `user.message`, `assistant.message`,
259259
`tool.execution_start`, `tool.execution_complete`, `hook.start`, `hook.end`,
260260
`subagent.started`, `subagent.completed`, `session.plan_changed`,
261261
`session.compaction_start`, `session.compaction_complete`,
262+
`permission.requested`, `permission.completed`,
262263
`session.permissions_changed`, and `external_tool.*`. Unrecognized types stay
263264
explicit `metadata.*` events rather than being dropped or reinterpreted.
264265

265-
Keep three Copilot boundaries explicit:
266-
267-
- Copilot transcripts record no per-response model token usage. Subagent
268-
totals (`totalTokens`) and `preCompactionTokens` are aggregates and never
269-
become per-response usage. Usage evidence requires the opt-in OpenTelemetry
270-
export, which this workflow does not read.
266+
Keep four Copilot boundaries explicit:
267+
268+
- Copilot records `outputTokens` per assistant message and nothing else. Carry
269+
that field as partial per-response usage; never fill input tokens, cache
270+
tokens, or cost with zero. Subagent totals (`totalTokens`) and
271+
`preCompactionTokens` are aggregates and never become per-response usage.
272+
Complete usage evidence requires the opt-in OpenTelemetry export, which this
273+
workflow does not read.
274+
- Permission evidence comes from the `permission.requested` /
275+
`permission.completed` pair, joined on `requestId`. Retain only the request
276+
kind and the result decision. The prompt intent, paths, and commands are
277+
payloads and are never retained. `session.permissions_changed` reports a mode
278+
change, not a decision.
271279
- A session directory can match the workspace and carry no `events.jsonl`. That
272280
is partial coverage, not zero activity.
273281
- `~/.copilot/session-store.db` is documented as automatically managed. Never

scripts/agent-customize/cli.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ async function main() {
7373
codexHome: options["codex-home"],
7474
claudeHome: options["claude-home"],
7575
qwenHome: options["qwen-home"],
76+
copilotHome: options["copilot-home"],
7677
claudeStatePath: options["claude-state"] ?? options["claude-state-path"],
7778
codexAppPath: options["codex-app-path"],
7879
qoderSharedClientCacheRoot: options["qoder-shared-client-cache-root"] ?? options["shared-client-cache-root"],

scripts/session-analysis/platforms/copilot.mjs

Lines changed: 125 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,25 @@ function resultText(data) {
7777
return result?.content ?? result?.detailedContent ?? "";
7878
}
7979

80+
// Copilot permission records carry protocol enums (`read`, `write`, `shell`,
81+
// `approved`, `denied-interactively-by-user`, ...). The guard keeps unexpected
82+
// values out of normalized events so prompt text can never leak through a field
83+
// that is only meant to hold a bounded token.
84+
const PERMISSION_TOKEN_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/u;
85+
86+
function permissionToken(value) {
87+
const token = String(value ?? "").trim().toLowerCase();
88+
return PERMISSION_TOKEN_PATTERN.test(token) ? token : null;
89+
}
90+
91+
function permissionDecisionFor(resultKind) {
92+
const token = permissionToken(resultKind);
93+
if (!token) return null;
94+
if (token.startsWith("approved") || token.startsWith("allowed")) return "allowed";
95+
if (token.startsWith("denied") || token.startsWith("rejected")) return "denied";
96+
return token;
97+
}
98+
8099
/**
81100
* Normalize one Copilot `events.jsonl` record.
82101
*
@@ -116,6 +135,8 @@ function transcriptEvents(raw, sourceRef, options) {
116135

117136
if (rawType === "assistant.message") {
118137
const text = typeof data.content === "string" ? data.content : "";
138+
const outputTokens = Number(data.outputTokens);
139+
const usageObserved = Number.isFinite(outputTokens) && outputTokens >= 0;
119140
const event = {
120141
...base,
121142
type: "assistant",
@@ -126,8 +147,29 @@ function transcriptEvents(raw, sourceRef, options) {
126147
};
127148
if (text) event.userVisibleAssistantMessage = true;
128149
if (options.includeContent && text) event.content = text;
129-
if (data.model) event.model = data.model;
150+
// When usage is observed the model is attributed on the companion response
151+
// event instead, so a single response is not counted against two events.
152+
if (data.model && !usageObserved) event.model = data.model;
130153
events.push(event);
154+
if (usageObserved) {
155+
// Copilot reports output tokens per assistant message but never input
156+
// tokens or cost, and `isModelRequestEvent` deliberately ignores plain
157+
// `assistant` events. Emit a companion response event carrying only the
158+
// fields that were actually observed -- omitted token fields read as 0 in
159+
// `session-efficiency`, so no input usage or cost is invented here.
160+
events.push({
161+
...base,
162+
type: "model.response.completed",
163+
category: "model",
164+
model: data.model ?? null,
165+
modelUsage: { outputTokens },
166+
usageFieldsObserved: true,
167+
responseId: data.messageId ?? null,
168+
requestId: data.requestId ?? null,
169+
evidenceRef: evidenceRef(sourceRef, "model.response.completed"),
170+
summary: "assistant response usage (output tokens only)",
171+
});
172+
}
131173
return events;
132174
}
133175

@@ -248,6 +290,39 @@ function transcriptEvents(raw, sourceRef, options) {
248290
return events;
249291
}
250292

293+
if (rawType === "permission.requested" || rawType === "permission.completed") {
294+
// Copilot records permission handling as a request/result pair correlated by
295+
// `requestId`. Only the correlation id and bounded protocol enums are kept --
296+
// the prompt payload (`intention`, `path`, `paths`, `commands`) is dropped.
297+
//
298+
// The decision rides the result event alone. Attaching one to the request as
299+
// well would double-count every permission in the episode summaries, and
300+
// Copilot emits a request even when policy auto-approves, so treating the
301+
// request as a prompt would overstate friction.
302+
const requested = rawType === "permission.requested";
303+
const event = {
304+
...base,
305+
type: "control.permission",
306+
category: "control",
307+
lifecyclePhase: requested ? "request" : "result",
308+
evidenceRef: evidenceRef(sourceRef, rawType),
309+
summary: requested ? "permission requested" : "permission resolved",
310+
};
311+
// Deliberately not `toolInvocationId`: a tool call can be re-prompted, and
312+
// `dedupeEvents` keys on that field, which would drop real observations.
313+
if (typeof data.requestId === "string" && data.requestId) {
314+
event.permissionRequestId = data.requestId;
315+
}
316+
const kind = permissionToken(data.permissionRequest?.kind);
317+
if (kind) event.permissionKind = kind;
318+
if (!requested) {
319+
const decision = permissionDecisionFor(data.result?.kind);
320+
if (decision) event.permissionDecision = decision;
321+
}
322+
events.push(event);
323+
return events;
324+
}
325+
251326
if (rawType === "session.permissions_changed" || rawType === "session.mode_changed") {
252327
events.push({
253328
...base,
@@ -305,6 +380,8 @@ async function probeSessionDirectory(sessionDir, workspace) {
305380
transcriptAvailable: await pathExists(transcriptPath),
306381
cwd: descriptor.cwd,
307382
records: 0,
383+
conversationRecords: 0,
384+
requestRecords: 0,
308385
firstSeen: null,
309386
lastSeen: null,
310387
workspaceMatch: isWorkspaceMatch(descriptor.cwd, workspace),
@@ -315,6 +392,12 @@ async function probeSessionDirectory(sessionDir, workspace) {
315392

316393
await forEachJsonLine(transcriptPath, (raw) => {
317394
summary.records += 1;
395+
if (raw?.type === "user.message" || raw?.type === "assistant.message") {
396+
summary.conversationRecords += 1;
397+
}
398+
if (raw?.type === "assistant.message" && Number.isFinite(Number(raw?.data?.outputTokens))) {
399+
summary.requestRecords += 1;
400+
}
318401
if (raw?.type === "session.start") {
319402
const data = raw?.data ?? {};
320403
if (data.sessionId) summary.sessionId = data.sessionId;
@@ -335,16 +418,35 @@ async function probeSessionDirectory(sessionDir, workspace) {
335418
* A Copilot session directory can exist and match the workspace while carrying
336419
* no `events.jsonl`. That state stays explicit instead of collapsing into zero
337420
* activity or a clean result.
421+
*
422+
* The payload populates the canonical `session-core-facts` transcript fields so
423+
* public facts never report unmapped evidence as a confirmed zero. Copilot has
424+
* no terminal source, so `terminalOnly` is a measured zero rather than an
425+
* unknown, and sessions with a missing or empty transcript surface as
426+
* `unreadable`. Copilot-specific counters are kept alongside for warnings and
427+
* are dropped by the bounded public schema.
338428
*/
339-
function buildCopilotSourceCoverage({ scope, roots, matched, inWindow }) {
429+
function buildCopilotSourceCoverage({ scope, roots, matched, inWindow, inWindowProbes = [] }) {
340430
const root = roots.find((entry) => entry.kind === "copilot-session-jsonl");
341431
const workspaceSessions = matched.length;
342432
const withTranscript = matched.filter((probe) => probe.transcriptAvailable);
343433
const withoutTranscript = workspaceSessions - withTranscript.length;
434+
const timeUnobservedProbes = matched.filter((probe) => !probe.firstSeen && !probe.lastSeen);
344435
const timeUnobserved = withTranscript.filter((probe) => !probe.firstSeen && !probe.lastSeen).length;
345436
const emptyTranscripts = withTranscript.filter((probe) => probe.records === 0).length;
346437
const requestedWindow = scope.sinceTime !== null || scope.untilTime !== null;
347438

439+
// Mirrors the Cursor precedent. Without a requested window every matched
440+
// session stays relevant, so a session whose transcript is missing or empty
441+
// surfaces as `unreadable` instead of disappearing from the denominator.
442+
let relevant = matched;
443+
if (requestedWindow) {
444+
relevant = inWindowProbes.length > 0 ? inWindowProbes : timeUnobservedProbes;
445+
}
446+
const withConversation = relevant.filter((probe) => probe.conversationRecords > 0).length;
447+
const withRequest = relevant.filter((probe) => probe.requestRecords > 0).length;
448+
const unreadable = relevant.filter((probe) => !probe.transcriptAvailable || probe.records === 0).length;
449+
348450
let status = "observed";
349451
if (!root?.exists || workspaceSessions === 0) {
350452
status = "absent";
@@ -361,17 +463,27 @@ function buildCopilotSourceCoverage({ scope, roots, matched, inWindow }) {
361463
transcript: {
362464
sourceAvailable: Boolean(root?.exists),
363465
workspaceSessions,
466+
inWindowSessions: inWindow.length,
467+
outOfWindowSessions: Math.max(workspaceSessions - inWindow.length - timeUnobservedProbes.length, 0),
468+
timeUnobservedSessions: timeUnobserved,
469+
relevantSessions: relevant.length,
470+
withConversation,
471+
withRequest,
472+
// Copilot exposes no terminal-only source, so this is measured, not unknown.
473+
terminalOnly: 0,
474+
unreadable,
475+
// Copilot-specific detail retained for adapter warnings.
364476
withTranscript: withTranscript.length,
365477
withoutTranscript,
366478
emptyTranscripts,
367-
timeUnobservedSessions: timeUnobserved,
368-
inWindowSessions: inWindow.length,
369479
},
370480
usage: {
371-
// Copilot transcripts carry no per-response model usage. Subagent and
481+
// Copilot records output tokens per assistant message but never input
482+
// tokens or cost, so per-response usage is partial. Subagent and
372483
// compaction totals are aggregates and are never projected as per-response
373484
// usage.
374-
perResponseUsageObserved: false,
485+
perResponseUsageObserved: withRequest > 0,
486+
perResponseUsageFields: ["outputTokens"],
375487
},
376488
};
377489
}
@@ -459,13 +571,15 @@ export class CopilotSessionAnalyzer extends SessionAnalyzer {
459571
if (probe?.workspaceMatch) matched.push(probe);
460572
}
461573

462-
const inWindow = matched
574+
const inWindowProbes = matched
463575
.filter((probe) => probe.transcriptAvailable)
464576
.filter((probe) => {
465577
const timestamp = probe.lastSeen ?? probe.firstSeen;
466578
if ((scope.sinceTime !== null || scope.untilTime !== null) && !timestamp) return false;
467579
return withinTimeRange(timestamp, scope);
468-
})
580+
});
581+
582+
const inWindow = inWindowProbes
469583
.map((probe) => ({
470584
sessionId: probe.sessionId,
471585
workspace: scope.workspace,
@@ -485,7 +599,7 @@ export class CopilotSessionAnalyzer extends SessionAnalyzer {
485599
}))
486600
.sort((left, right) => (timestampMillis(right.lastSeen) ?? 0) - (timestampMillis(left.lastSeen) ?? 0));
487601

488-
scope._copilotSourceCoverage = buildCopilotSourceCoverage({ scope, roots, matched, inWindow });
602+
scope._copilotSourceCoverage = buildCopilotSourceCoverage({ scope, roots, matched, inWindow, inWindowProbes });
489603
return inWindow;
490604
}
491605

@@ -519,8 +633,8 @@ export class CopilotSessionAnalyzer extends SessionAnalyzer {
519633
async analysisWarnings(scope, _roots, _sessions) {
520634
const coverage = scope._copilotSourceCoverage;
521635
const warnings = [{
522-
code: "copilot-per-response-usage-unobserved",
523-
message: "Copilot transcripts do not record per-response model token usage; usage evidence requires the opt-in OpenTelemetry export.",
636+
code: "copilot-per-response-usage-partial",
637+
message: "Copilot transcripts record output tokens per assistant response but no input tokens, cache tokens, or cost; complete usage evidence requires the opt-in OpenTelemetry export.",
524638
}];
525639
if (!coverage || coverage.status === "absent") {
526640
warnings.push({

test/agent-customize.test.mjs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1761,3 +1761,43 @@ test("Copilot inventory without user-home authority keeps project scope only", a
17611761
await rm(fixture.root, { recursive: true, force: true });
17621762
}
17631763
});
1764+
1765+
test("agent-customize CLI honours --copilot-home instead of the real user home", async () => {
1766+
// Regression: the CLI advertised --copilot-home but never forwarded it, so an
1767+
// isolated home still resolved ~/.copilot and scanned the caller's real assets.
1768+
const fixture = await makeCopilotFixture();
1769+
try {
1770+
const result = runAgentCustomizeCli([
1771+
"inventory",
1772+
"--provider",
1773+
"copilot",
1774+
"--workspace",
1775+
fixture.workspace,
1776+
"--copilot-home",
1777+
fixture.copilotHome,
1778+
]);
1779+
1780+
assert.equal(result.status, 0, result.stderr);
1781+
const inventory = JSON.parse(result.stdout);
1782+
1783+
// The resolved home must be the override, never the caller's real ~/.copilot.
1784+
assert.equal(inventory.copilotHome, fixture.copilotHome);
1785+
assert.notEqual(inventory.copilotHome, path.join(os.homedir(), ".copilot"));
1786+
1787+
// The inventory itself must also come from the override.
1788+
assert.equal(inventory.diagnostics.installedPluginState, "copilot-config");
1789+
assert.deepEqual(inventory.plugins.map((plugin) => plugin.id), ["acme/delivery"]);
1790+
assert.deepEqual(
1791+
inventory.manage.skills.map((skill) => skill.name).sort(),
1792+
["review-change", "ship-release", "user-skill"],
1793+
);
1794+
for (const item of [...inventory.manage.skills, ...inventory.manage.mcps]) {
1795+
assert.ok(
1796+
!item.evidence?.path || !item.evidence.path.startsWith(path.join(os.homedir(), ".copilot")),
1797+
`inventory item escaped the override home: ${item.evidence?.path}`,
1798+
);
1799+
}
1800+
} finally {
1801+
await rm(fixture.root, { recursive: true, force: true });
1802+
}
1803+
});

0 commit comments

Comments
 (0)