Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
dd5e93c
fix(core): sync loaded-skill state with history eviction; add user /u…
Aug 10, 2026
6be1366
fix(core): address #8900 review — kept-suppression, hook dedup, i18n
Aug 11, 2026
68fad4c
fix(core): widen hookConfigKey to HookConfig so tsc --build passes
Aug 11, 2026
f87e889
fix(core): address R2 review — positive body check, rewind sync, dedup
Aug 11, 2026
19b901b
fix(core): address R3 review — sync-before-disarm, body guard, i18n p…
Aug 11, 2026
64a6a3e
fix(test): use buildSkillLlmContent in skill-eviction test fixtures
Aug 11, 2026
7b1dd42
fix(core): R4-1 — unloadSkillBody positive body check
Aug 12, 2026
76006c4
fix(core): move clearLoadedSkillTracking to GeminiChat layer
Aug 12, 2026
3cb3cf7
chore: merge main into feat/issue-6762-unskill
qwen-code-dev-bot Aug 12, 2026
f61a2f7
fix(core): guard chat-layer skill tracking clears with change checks
Aug 13, 2026
ac0c8c3
Merge remote-tracking branch 'origin/main' into feat/issue-6762-unskill
Aug 13, 2026
0ea2487
fix(core): keep loaded-skill tracking consistent across forked chats …
Aug 13, 2026
14df6c0
fix(core): reconcile loaded-skill tracking after history rewrites
Aug 13, 2026
d3fb5b0
fix(core): remove duplicate mock property breaking tsc --build
Aug 13, 2026
683169d
fix(core): harden loaded-skill tracking per R10 review
Aug 14, 2026
e7b7b01
fix(core): residency-aware settle reconcile and unskill pre-init guard
Aug 14, 2026
4cfe864
fix(core): harden skill-body residency check; document settle trade-off
Aug 17, 2026
8cbfecd
Merge origin/main into feat/issue-6762-unskill
Aug 18, 2026
de794a6
Merge remote-tracking branch 'origin/main' into feat/issue-6762-unskill
Aug 18, 2026
94ddbad
fix(core): blanket-clear tracking when a stripped skill body is unres…
Aug 18, 2026
03988a3
fix(core): close unresolvable-body gaps in strip sync and /unskill
Aug 18, 2026
f03e9f5
chore: drop stray local files accidentally carried in PR diff
Aug 18, 2026
000f5fe
fix(core): broaden /unskill refusal to any unattributable skill result
Aug 18, 2026
58c7a63
fix(core): scope /unskill markers to Skill responses; never enlarge t…
Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,8 @@ describe('Session', () => {
truncateHistory: vi.fn(),
stripThoughtsFromHistory: vi.fn(),
stripOrphanedUserEntriesFromHistory: vi.fn().mockReturnValue([]),
resolveLoadedSkillNamesInEntries: vi.fn().mockReturnValue([]),
reconcileLoadedSkillTracking: vi.fn(),
setTools: vi.fn(),
} as unknown as GeminiChat;
mockGeminiClient = {
Expand Down Expand Up @@ -2619,6 +2621,12 @@ describe('Session', () => {
mockChat.getHistory = vi
.fn()
.mockReturnValue([{ role: 'user', parts: [{ text: 'unanswered' }] }]);
mockChat.stripOrphanedUserEntriesFromHistory = vi
.fn()
.mockReturnValue([{ role: 'user', parts: [{ text: 'unanswered' }] }]);
mockChat.resolveLoadedSkillNamesInEntries = vi
.fn()
.mockReturnValue(['demo']);
// Force the continuation send to fail NON-cancelled (session token limit)
// so it hits the `!responseStream` branch — the data-loss window.
mockConfig.getSessionTokenLimit = vi.fn().mockReturnValue(100);
Expand Down Expand Up @@ -2647,6 +2655,13 @@ describe('Session', () => {
]),
}),
);
// The strip un-tracked any skill body it removed; once the orphan is
// preserved back, the settle reconcile rebuilds tracking from the
// settled history (residency aware — not an additive re-track of the
// stashed names).
expect(mockChat.reconcileLoadedSkillTracking).toHaveBeenCalledWith(
'acpContinuationSettle',
);
});

it('restores the orphaned turn when a continuation send throws (no data loss)', async () => {
Expand All @@ -2659,6 +2674,9 @@ describe('Session', () => {
mockChat.stripOrphanedUserEntriesFromHistory = vi
.fn()
.mockReturnValue([{ role: 'user', parts: [{ text: 'unanswered' }] }]);
mockChat.resolveLoadedSkillNamesInEntries = vi
.fn()
.mockReturnValue(['demo']);
// No token limit, so we reach the send; the send then throws.
mockConfig.getSessionTokenLimit = vi.fn().mockReturnValue(0);
mockChat.sendMessageStream = vi
Expand All @@ -2684,6 +2702,9 @@ describe('Session', () => {
]),
}),
);
expect(mockChat.reconcileLoadedSkillTracking).toHaveBeenCalledWith(
'acpContinuationSettle',
);
});

it('rejects (accepted:false) when a prompt is already in flight', async () => {
Expand Down
42 changes: 42 additions & 0 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4255,6 +4255,18 @@ export class Session implements SessionContext {
// — so hold it (and a push-count snapshot) to restore on that path.
let strippedOrphanEntries: Content[] | null = null;
let orphanPushCountSnapshot = 0;
// The continuation strip un-tracks any skill body it removes;
// every terminal path of this turn re-adds the stripped content
// (send re-push, catch restore, or the abort addHistory). The
// stashed names gate the settle reconcile in the outer finally,
// which rebuilds tracking from the SETTLED history — residency
// aware, because a mid-turn rewrite can blank or summarize away
// the re-pushed body again. Names are resolved AT STRIP TIME:
// a compression inside the continuation send can summarize away
// the model-side functionCalls needed for pairing, so
// re-deriving the gate from the post-send history would miss
// them.
let orphanStrippedSkillNames: string[] = [];
if (goalTurn?.origin === 'runtime') {
this.config.getChatRecordingService()?.recordGoalRuntimeMessage(
modelPromptBlocks
Expand Down Expand Up @@ -4290,6 +4302,12 @@ export class Session implements SessionContext {
strippedOrphanEntries =
this.#getCurrentChat().stripOrphanedUserEntriesFromHistory() ??
null;
orphanStrippedSkillNames =
(strippedOrphanEntries?.length ?? 0) > 0
? this.#getCurrentChat().resolveLoadedSkillNamesInEntries(
strippedOrphanEntries!,
)
: [];
orphanPushCountSnapshot =
this.#getCurrentChat().getUserContentPushCount?.() ?? 0;
continuationParts = recoveryPlan.continuation.parts;
Expand Down Expand Up @@ -4961,6 +4979,30 @@ export class Session implements SessionContext {
}
return result;
} finally {
// Fires on every terminal path of the turn — including the
// top-of-loop abort return that re-adds the stripped content
// via addHistory without entering the send-try's finally.
if (orphanStrippedSkillNames.length > 0) {
// Residency-aware, not additive: a mid-turn rewrite
// (tryCompress / microcompaction) can blank or summarize
// away the re-pushed body and correctly un-track it;
// re-adding the stashed names anyway would resurrect the
// ghost the strip just removed. Mirrors the TUI twin in
// restoreStrippedRetryEntries (client.ts).
//
// Accepted trade-off: the pre-send tryCompress can also
// summarize away the re-pushed body's pairing model-side
// functionCall, leaving the body resident-but-untracked at
// settle — the next invoke then injects one duplicate body
// and self-heals (the documented direction in
// unloadSkillsFromEntries). Rebuilding from the stashed ids
// would add a second residency-truth path beside the
// reconcile; not worth it for a self-healing duplicate.
this.#getCurrentChat().reconcileLoadedSkillTracking(
'acpContinuationSettle',
);
Comment thread
ZijianZhang989 marked this conversation as resolved.
Comment thread
ZijianZhang989 marked this conversation as resolved.
orphanStrippedSkillNames = [];
}
logConversationFinishedEvent(
this.config,
new ConversationFinishedEvent(
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/i18n/locales/ca.js
Original file line number Diff line number Diff line change
Expand Up @@ -2849,4 +2849,21 @@ export default {
'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.':
"Els canvis del gestor d'habilitats automàtiques només estan disponibles en espais de treball de confiança. Marca aquesta carpeta com a fiable amb `/trust` i torna-ho a provar.",
'Kept model as {{model}}': 'Model mantingut com a {{model}}',

// /unskill command
'Unload a loaded skill body from context, freeing its tokens for the rest of the session (costs one prompt-cache re-fill). The skill stays available and reloads in full on its next invocation.':
'Descarrega del context el cos d’una habilitat carregada, alliberant-ne els tokens per a la resta de la sessió (a costa d’un reompliment de la memòria cau de prompt). L’habilitat continua disponible i es recarrega completament en la propera invocació.',
'Usage: /unskill <skill-name>': 'Ús: /unskill <skill-name>',
'Could not retrieve skill manager.':
'No s’ha pogut obtenir el gestor d’habilitats.',
'Skill "{{name}}" is not loaded in context.':
'L’habilitat "{{name}}" no està carregada al context.',
'Skill "{{name}}" had no body left in context; tracking cleared so it can be reloaded.':
'L’habilitat "{{name}}" ja no tenia cos al context; s’ha esborrat el seguiment perquè es pugui recarregar.',
'Skill "{{name}}" could not be unloaded safely: a skill body in context has no call id and cannot be attributed. Tracking is kept to avoid a duplicate injection on reload.':
"L'habilitat \"{{name}}\" no s'ha pogut descarregar amb seguretat: un cos d'habilitat al context no té identificador de crida i no es pot atribuir. Es manté el seguiment per evitar una injecció duplicada en recarregar.",
'Unloaded skill "{{name}}" (~{{tokens}} tokens freed). Invoke it again to reload.':
'Habilitat "{{name}}" descarregada (~{{tokens}} tokens alliberats). Invoca-la de nou per recarregar-la.',
'"{{name}}" is not a skill (it may be a model-invocable command); /unskill only unloads skill bodies.':
'"{{name}}" no és una habilitat (pot ser una comanda invocable pel model); /unskill només descarrega cossos d’habilitats.',
};
17 changes: 17 additions & 0 deletions packages/cli/src/i18n/locales/de.js
Original file line number Diff line number Diff line change
Expand Up @@ -2329,4 +2329,21 @@ export default {
'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.':
'Änderungen durch den Auto-Skill-Kurator sind nur in vertrauenswürdigen Arbeitsbereichen verfügbar. Stufen Sie diesen Ordner mit `/trust` als vertrauenswürdig ein und versuchen Sie es erneut.',
'Kept model as {{model}}': 'Modell als {{model}} beibehalten',

// /unskill command
'Unload a loaded skill body from context, freeing its tokens for the rest of the session (costs one prompt-cache re-fill). The skill stays available and reloads in full on its next invocation.':
'Entlädt den Body eines geladenen Skills aus dem Kontext und gibt dessen Tokens für den Rest der Sitzung frei (kostet eine erneute Befüllung des Prompt-Caches). Der Skill bleibt verfügbar und wird beim nächsten Aufruf vollständig neu geladen.',
'Usage: /unskill <skill-name>': 'Verwendung: /unskill <skill-name>',
'Could not retrieve skill manager.':
'Skill-Manager konnte nicht abgerufen werden.',
'Skill "{{name}}" is not loaded in context.':
'Skill "{{name}}" ist nicht im Kontext geladen.',
'Skill "{{name}}" had no body left in context; tracking cleared so it can be reloaded.':
'Skill "{{name}}" hatte keinen Body mehr im Kontext; die Nachverfolgung wurde zurückgesetzt, sodass er neu geladen werden kann.',
'Skill "{{name}}" could not be unloaded safely: a skill body in context has no call id and cannot be attributed. Tracking is kept to avoid a duplicate injection on reload.':
'Der Skill "{{name}}" konnte nicht sicher entladen werden: Ein Skill-Body im Kontext hat keine Aufruf-ID und kann nicht zugeordnet werden. Die Nachverfolgung bleibt bestehen, um eine doppelte Injektion beim Neuladen zu vermeiden.',
'Unloaded skill "{{name}}" (~{{tokens}} tokens freed). Invoke it again to reload.':
'Skill "{{name}}" entladen (~{{tokens}} Tokens freigegeben). Rufen Sie ihn erneut auf, um ihn neu zu laden.',
'"{{name}}" is not a skill (it may be a model-invocable command); /unskill only unloads skill bodies.':
'"{{name}}" ist kein Skill (möglicherweise ein vom Modell aufrufbarer Befehl); /unskill entlädt nur Skill-Bodys.',
};
16 changes: 16 additions & 0 deletions packages/cli/src/i18n/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -2839,4 +2839,20 @@ export default {
'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.':
'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.',
'Kept model as {{model}}': 'Kept model as {{model}}',

// /unskill command
'Unload a loaded skill body from context, freeing its tokens for the rest of the session (costs one prompt-cache re-fill). The skill stays available and reloads in full on its next invocation.':
'Unload a loaded skill body from context, freeing its tokens for the rest of the session (costs one prompt-cache re-fill). The skill stays available and reloads in full on its next invocation.',
Comment thread
ZijianZhang989 marked this conversation as resolved.
'Usage: /unskill <skill-name>': 'Usage: /unskill <skill-name>',
'Could not retrieve skill manager.': 'Could not retrieve skill manager.',
'Skill "{{name}}" is not loaded in context.':
'Skill "{{name}}" is not loaded in context.',
'Skill "{{name}}" had no body left in context; tracking cleared so it can be reloaded.':
'Skill "{{name}}" had no body left in context; tracking cleared so it can be reloaded.',
'Skill "{{name}}" could not be unloaded safely: a skill body in context has no call id and cannot be attributed. Tracking is kept to avoid a duplicate injection on reload.':
'Skill "{{name}}" could not be unloaded safely: a skill body in context has no call id and cannot be attributed. Tracking is kept to avoid a duplicate injection on reload.',
'Unloaded skill "{{name}}" (~{{tokens}} tokens freed). Invoke it again to reload.':
'Unloaded skill "{{name}}" (~{{tokens}} tokens freed). Invoke it again to reload.',
'"{{name}}" is not a skill (it may be a model-invocable command); /unskill only unloads skill bodies.':
'"{{name}}" is not a skill (it may be a model-invocable command); /unskill only unloads skill bodies.',
};
17 changes: 17 additions & 0 deletions packages/cli/src/i18n/locales/fr.js
Original file line number Diff line number Diff line change
Expand Up @@ -2335,4 +2335,21 @@ export default {
'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.':
'Les modifications du gestionnaire de compétences automatiques ne sont disponibles que dans les espaces de travail approuvés. Marquez ce dossier comme approuvé avec `/trust`, puis réessayez.',
'Kept model as {{model}}': 'Modèle conservé : {{model}}',

// /unskill command
'Unload a loaded skill body from context, freeing its tokens for the rest of the session (costs one prompt-cache re-fill). The skill stays available and reloads in full on its next invocation.':
'Décharge du contexte le corps d’une compétence chargée, libérant ses tokens pour le reste de la session (au prix d’un nouveau remplissage du cache d’invite). La compétence reste disponible et se recharge intégralement à sa prochaine invocation.',
'Usage: /unskill <skill-name>': 'Utilisation : /unskill <skill-name>',
'Could not retrieve skill manager.':
'Impossible de récupérer le gestionnaire de compétences.',
'Skill "{{name}}" is not loaded in context.':
'La compétence "{{name}}" n’est pas chargée dans le contexte.',
'Skill "{{name}}" had no body left in context; tracking cleared so it can be reloaded.':
'La compétence "{{name}}" n’avait plus de corps dans le contexte ; le suivi a été réinitialisé pour permettre son rechargement.',
'Skill "{{name}}" could not be unloaded safely: a skill body in context has no call id and cannot be attributed. Tracking is kept to avoid a duplicate injection on reload.':
'La compétence "{{name}}" n’a pas pu être déchargée en toute sécurité : un corps de compétence dans le contexte n’a pas d’identifiant d’appel et ne peut pas être attribué. Le suivi est conservé pour éviter une injection en double au rechargement.',
'Unloaded skill "{{name}}" (~{{tokens}} tokens freed). Invoke it again to reload.':
'Compétence "{{name}}" déchargée (~{{tokens}} tokens libérés). Invoquez-la à nouveau pour la recharger.',
'"{{name}}" is not a skill (it may be a model-invocable command); /unskill only unloads skill bodies.':
'"{{name}}" n’est pas une compétence (il peut s’agir d’une commande invocable par le modèle) ; /unskill ne décharge que les corps de compétences.',
};
17 changes: 17 additions & 0 deletions packages/cli/src/i18n/locales/ja.js
Original file line number Diff line number Diff line change
Expand Up @@ -2096,4 +2096,21 @@ export default {
'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.':
'自動スキル管理による変更は信頼済みのワークスペースでのみ利用できます。`/trust` でこのフォルダーを信頼してから、もう一度お試しください。',
'Kept model as {{model}}': 'モデルは {{model}} のままです',

// /unskill command
'Unload a loaded skill body from context, freeing its tokens for the rest of the session (costs one prompt-cache re-fill). The skill stays available and reloads in full on its next invocation.':
'読み込み済みスキルの本文をコンテキストからアンロードし、このセッションの残りで使えるトークンを解放します(プロンプトキャッシュの再充填が1回発生します)。スキルは利用可能なまま保持され、次回呼び出し時に全文が再読み込みされます。',
'Usage: /unskill <skill-name>': '使用法: /unskill <skill-name>',
'Could not retrieve skill manager.':
'スキルマネージャーを取得できませんでした。',
'Skill "{{name}}" is not loaded in context.':
'スキル "{{name}}" はコンテキストに読み込まれていません。',
'Skill "{{name}}" had no body left in context; tracking cleared so it can be reloaded.':
'スキル "{{name}}" の本文はコンテキストに残っていません。追跡状態をクリアしたので再読み込みできます。',
'Skill "{{name}}" could not be unloaded safely: a skill body in context has no call id and cannot be attributed. Tracking is kept to avoid a duplicate injection on reload.':
'スキル "{{name}}" を安全にアンロードできませんでした:コンテキスト内のスキル本文に呼び出し id がなく、帰属を特定できません。再読み込み時の重複注入を避けるため追跡を保持します。',
'Unloaded skill "{{name}}" (~{{tokens}} tokens freed). Invoke it again to reload.':
'スキル "{{name}}" をアンロードしました(約 {{tokens}} トークンを解放)。再度呼び出すと再読み込みされます。',
'"{{name}}" is not a skill (it may be a model-invocable command); /unskill only unloads skill bodies.':
'"{{name}}" はスキルではありません(モデルが呼び出せるコマンドの可能性があります)。/unskill はスキル本文のみをアンロードします。',
};
17 changes: 17 additions & 0 deletions packages/cli/src/i18n/locales/pt.js
Original file line number Diff line number Diff line change
Expand Up @@ -2313,4 +2313,21 @@ export default {
'Auto-skill curator changes are only available in trusted workspaces. Trust this folder via `/trust` and try again.':
'As alterações do gerenciador de habilidades automáticas estão disponíveis apenas em espaços de trabalho confiáveis. Marque esta pasta como confiável usando `/trust` e tente novamente.',
'Kept model as {{model}}': 'Modelo mantido como {{model}}',

// /unskill command
'Unload a loaded skill body from context, freeing its tokens for the rest of the session (costs one prompt-cache re-fill). The skill stays available and reloads in full on its next invocation.':
'Descarrega do contexto o corpo de uma habilidade carregada, liberando seus tokens para o restante da sessão (ao custo de um reabastecimento do cache de prompt). A habilidade permanece disponível e é recarregada por completo na próxima invocação.',
'Usage: /unskill <skill-name>': 'Uso: /unskill <skill-name>',
'Could not retrieve skill manager.':
'Não foi possível obter o gerenciador de habilidades.',
'Skill "{{name}}" is not loaded in context.':
'A habilidade "{{name}}" não está carregada no contexto.',
'Skill "{{name}}" had no body left in context; tracking cleared so it can be reloaded.':
'A habilidade "{{name}}" não tinha mais corpo no contexto; o rastreamento foi limpo para que ela possa ser recarregada.',
'Skill "{{name}}" could not be unloaded safely: a skill body in context has no call id and cannot be attributed. Tracking is kept to avoid a duplicate injection on reload.':
'A habilidade "{{name}}" não pôde ser descarregada com segurança: um corpo de habilidade no contexto não tem id de chamada e não pode ser atribuído. O rastreamento é mantido para evitar uma injeção duplicada ao recarregar.',
'Unloaded skill "{{name}}" (~{{tokens}} tokens freed). Invoke it again to reload.':
'Habilidade "{{name}}" descarregada (~{{tokens}} tokens liberados). Invoque-a novamente para recarregar.',
'"{{name}}" is not a skill (it may be a model-invocable command); /unskill only unloads skill bodies.':
'"{{name}}" não é uma habilidade (pode ser um comando invocável pelo modelo); /unskill descarrega apenas corpos de habilidades.',
};
Loading
Loading