diff --git a/package-lock.json b/package-lock.json
index 74d7321..76d1a55 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,7 +21,7 @@
"yaml": "^2.9.1"
},
"devDependencies": {
- "@agentclientprotocol/sdk": "latest",
+ "@agentclientprotocol/sdk": "~2.5.0",
"@lucide/svelte": "^1.0.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.64.0",
@@ -50,13 +50,19 @@
"license": "MIT"
},
"node_modules/@agentclientprotocol/sdk": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.25.0.tgz",
- "integrity": "sha512-wU1VgXNtMvdVotX49txc3WJUDV+/QbLpsgjMvFhlRmp37osdLbI7L7y+iwAlQATwfjLxcv1r1p3ZxZBcXlGhcQ==",
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-2.5.0.tgz",
+ "integrity": "sha512-MFlXeIuwN6nZSK45SAsSnVjbLgQ2r4fnthoF1lQoGJP3PdaPRpOZeCF2Dds8S4cN8yZP5QbxB1bkJccGskmcYw==",
"dev": true,
"license": "Apache-2.0",
"peerDependencies": {
- "zod": "^3.25.0 || ^4.0.0"
+ "@modelcontextprotocol/server": "^2.4.0",
+ "zod": "^3.25.76 || ^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@modelcontextprotocol/server": {
+ "optional": true
+ }
}
},
"node_modules/@antfu/install-pkg": {
diff --git a/package.json b/package.json
index 9934210..da967d4 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,7 @@
"package:cef": "bash scripts/cef/package.sh"
},
"devDependencies": {
- "@agentclientprotocol/sdk": "latest",
+ "@agentclientprotocol/sdk": "~2.5.0",
"@lucide/svelte": "^1.0.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.64.0",
diff --git a/src/app.css b/src/app.css
index 5097b45..2caddda 100644
--- a/src/app.css
+++ b/src/app.css
@@ -8328,6 +8328,98 @@ a.session-delegate-session:hover {
gap: 0.65rem;
}
+/* Context-compaction boundary: a full-width divider, deliberately unlike a turn bubble. */
+.session-compaction {
+ display: grid;
+ gap: 0.4rem;
+ padding-block: 0.35rem;
+ color: var(--muted);
+}
+
+.session-compaction-rule {
+ height: 1px;
+ background: linear-gradient(90deg, transparent, var(--border-strong), transparent);
+}
+
+.session-compaction-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ font-size: 0.76rem;
+}
+
+.session-compaction-mark {
+ display: inline-flex;
+ color: var(--accent);
+}
+
+.session-compaction-title {
+ font-weight: 600;
+ letter-spacing: 0.01em;
+}
+
+.session-compaction-title-live {
+ background: linear-gradient(90deg, var(--muted) 20%, var(--accent) 50%, var(--muted) 80%);
+ background-size: 200% 100%;
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+ animation: state-skeleton-shimmer 1.6s ease-in-out infinite;
+}
+
+.session-compaction-detail {
+ font-variant-numeric: tabular-nums;
+}
+
+.session-compaction-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.1rem 0.4rem;
+ border: 0;
+ border-radius: 999px;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ cursor: pointer;
+}
+
+.session-compaction-toggle:hover,
+.session-compaction-toggle:focus-visible {
+ background: var(--accent-dim);
+ color: var(--text);
+}
+
+.session-compaction-chevron-open {
+ transform: rotate(180deg);
+}
+
+.session-compaction-failed .session-compaction-mark,
+.session-compaction-failed .session-compaction-detail {
+ color: var(--danger);
+}
+
+.session-compaction-cancelled .session-compaction-mark {
+ color: var(--muted);
+}
+
+.session-compaction-summary {
+ margin-inline: auto;
+ max-width: 46rem;
+ padding: 0.6rem 0.85rem;
+ border: 1px solid var(--border);
+ border-radius: 0.6rem;
+ font-size: 0.85rem;
+ color: var(--text);
+}
+
+.session-compaction-summary-live {
+ margin: 0;
+ white-space: pre-wrap;
+}
+
.session-prompt-error {
display: grid;
grid-template-columns: 1.7rem minmax(0, 1fr);
diff --git a/src/lib/components/automations/ScheduleDialog.test.ts b/src/lib/components/automations/ScheduleDialog.test.ts
index d6baf66..cdf4685 100644
--- a/src/lib/components/automations/ScheduleDialog.test.ts
+++ b/src/lib/components/automations/ScheduleDialog.test.ts
@@ -194,7 +194,7 @@ describe('ScheduleDialog', () => {
it('shows the agent reason and copies the full error details', async () => {
const writeText = vi.fn(async (_text: string) => undefined);
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
- const { RequestError } = await import('@agentclientprotocol/sdk');
+ const { RequestError } = await import('@agentclientprotocol/sdk/unstable');
agentsStore.createSchedule.mockRejectedValueOnce(new RequestError(-32602, 'Invalid params', { error: 'missing field `type`' }));
vi.spyOn(console, 'error').mockImplementation(() => undefined);
await openDialog({ agentId: 'agent-1', cwd: '/home/me/projects/querymt', prompt: 'Summarise open PRs' });
diff --git a/src/lib/components/primitives/AcpSessionList.svelte b/src/lib/components/primitives/AcpSessionList.svelte
index 1b72d81..e07aae6 100644
--- a/src/lib/components/primitives/AcpSessionList.svelte
+++ b/src/lib/components/primitives/AcpSessionList.svelte
@@ -1,5 +1,5 @@
diff --git a/src/lib/components/primitives/ActiveSessionView.svelte b/src/lib/components/primitives/ActiveSessionView.svelte
index 64d3cee..2c8239a 100644
--- a/src/lib/components/primitives/ActiveSessionView.svelte
+++ b/src/lib/components/primitives/ActiveSessionView.svelte
@@ -3,6 +3,7 @@
import { provideToolContentFetcher, type ToolContentFetcher } from '$lib/components/session/tool-content-context';
import Conversation from '$lib/components/ai-elements/conversation.svelte';
import SessionTurn from '$lib/components/session/SessionTurn.svelte';
+ import SessionCompactionDivider from '$lib/components/session/SessionCompactionDivider.svelte';
import SessionTurnDiffPanel from '$lib/components/session/SessionTurnDiffPanel.svelte';
import { buildSessionConversation } from '$lib/domain/session-conversation';
import { getForkTarget } from '$lib/domain/session-fork';
@@ -395,6 +396,8 @@
{#each windowedTurns as item (item.type === 'turn' ? item.turn.id : item.key)}
{#if item.type === 'spacer'}
+ {:else if item.turn.compaction}
+
{:else}
{@const turn = item.turn}
{@const forkTarget = getForkTarget(turn)}
diff --git a/src/lib/components/primitives/SessionComposer.svelte b/src/lib/components/primitives/SessionComposer.svelte
index 05cc8c0..789a303 100644
--- a/src/lib/components/primitives/SessionComposer.svelte
+++ b/src/lib/components/primitives/SessionComposer.svelte
@@ -20,7 +20,7 @@
import { getModeToneIndex } from '$lib/domain/session-mode-tone';
import type { ComposerOption, ModelEntry, ModelInfo, PromptAttachment, SessionContentBlock, SessionInputDeliveryMode } from '$lib/domain/types';
import type { SendShortcut } from '$lib/stores/chat-preferences.svelte';
- import type { SessionConfigOption } from '@agentclientprotocol/sdk';
+ import type { SessionConfigOption } from '@agentclientprotocol/sdk/unstable';
let modelPickerRef: { openPicker: () => Promise } | null = null;
let fileInputElement = $state(null);
diff --git a/src/lib/components/primitives/SessionTurnSummary.svelte b/src/lib/components/primitives/SessionTurnSummary.svelte
index d562ae6..3a1d2fc 100644
--- a/src/lib/components/primitives/SessionTurnSummary.svelte
+++ b/src/lib/components/primitives/SessionTurnSummary.svelte
@@ -1,5 +1,5 @@
+
+
+
+
+
+ {#if expanded && hasSummary}
+
+ {#if summaryHtml}
+
{@html summaryHtml}
+ {:else}
+
{compaction.summary}
+ {/if}
+
+ {/if}
+
diff --git a/src/lib/components/session/SessionCompactionDivider.test.ts b/src/lib/components/session/SessionCompactionDivider.test.ts
new file mode 100644
index 0000000..27ac54d
--- /dev/null
+++ b/src/lib/components/session/SessionCompactionDivider.test.ts
@@ -0,0 +1,50 @@
+import '@testing-library/jest-dom/vitest';
+import { cleanup, fireEvent, render, screen } from '@testing-library/svelte';
+import { afterEach, describe, expect, it } from 'vitest';
+import SessionCompactionDivider from './SessionCompactionDivider.svelte';
+import type { SessionCompactionItem } from '$lib/domain/types';
+
+function item(overrides: Partial = {}): SessionCompactionItem {
+ return { id: 'c1', status: 'completed', summary: '## Goals\n\nShip it.', tokensBefore: 150_000, tokensAfter: 20_000, ...overrides };
+}
+
+afterEach(cleanup);
+
+describe('SessionCompactionDivider', () => {
+ it('renders a boundary with the token drop and a collapsed, toggleable markdown summary', async () => {
+ const { container } = render(SessionCompactionDivider, { compaction: item() });
+ expect(screen.getByText('Context compacted')).toBeInTheDocument();
+ expect(screen.getByText('150k \u2192 20k tokens')).toBeInTheDocument();
+ expect(container.querySelector('.session-turn')).toBeNull();
+ expect(container.querySelector('.session-compaction-summary')).toBeNull();
+
+ const toggle = screen.getByRole('button', { name: /show summary/i });
+ expect(toggle).toHaveAttribute('aria-expanded', 'false');
+ await fireEvent.click(toggle);
+ expect(toggle).toHaveAttribute('aria-expanded', 'true');
+ expect(container.querySelector('.session-compaction-summary h2')?.textContent).toBe('Goals');
+ await fireEvent.click(toggle);
+ expect(container.querySelector('.session-compaction-summary')).toBeNull();
+ });
+
+ it('shows a live state with no toggle until summary text exists', () => {
+ const { container, rerender } = render(SessionCompactionDivider, {
+ compaction: item({ status: 'in_progress', summary: '', tokensAfter: null })
+ });
+ expect(screen.getByText('Compacting context\u2026')).toBeInTheDocument();
+ expect(container.querySelector('.animate-spin')).not.toBeNull();
+ expect(screen.queryByRole('button')).toBeNull();
+ return rerender({ compaction: item({ status: 'in_progress', summary: 'partial', tokensAfter: null }) }).then(() => {
+ expect(screen.getByRole('button', { name: /show summary/i })).toBeInTheDocument();
+ });
+ });
+
+ it('shows failure details in the failed tone', () => {
+ const { container } = render(SessionCompactionDivider, {
+ compaction: item({ status: 'failed', summary: '', error: 'provider down' })
+ });
+ expect(screen.getByText('Compaction failed')).toBeInTheDocument();
+ expect(screen.getByText('provider down')).toBeInTheDocument();
+ expect(container.querySelector('.session-compaction')).toHaveClass('session-compaction-failed');
+ });
+});
diff --git a/src/lib/components/session/SessionConfigPanel.svelte b/src/lib/components/session/SessionConfigPanel.svelte
index 96cfc04..26d31a9 100644
--- a/src/lib/components/session/SessionConfigPanel.svelte
+++ b/src/lib/components/session/SessionConfigPanel.svelte
@@ -10,7 +10,7 @@
getSelectConfigOptions
} from '$lib/querymt/config-options';
import type { ActiveSessionViewModel } from '$lib/domain/types';
- import type { SessionConfigOption, SessionConfigSelectOption } from '@agentclientprotocol/sdk';
+ import type { SessionConfigOption, SessionConfigSelectOption } from '@agentclientprotocol/sdk/unstable';
import type { Component } from 'svelte';
type SelectConfigOption = SessionConfigOption & { type: 'select' };
diff --git a/src/lib/components/session/SessionHeader.svelte b/src/lib/components/session/SessionHeader.svelte
index 2c1d1af..14c95c8 100644
--- a/src/lib/components/session/SessionHeader.svelte
+++ b/src/lib/components/session/SessionHeader.svelte
@@ -100,7 +100,7 @@
['submitting', 'thinking', 'streaming', 'tool-running'].includes(session.runState) ||
(!session.sessionId && ['thinking', 'cancelling'].includes(summaryStatus))
) {
- return { label: session.activityLabel?.startsWith('Cancelling') ? 'Cancelling' : session.activityLabel?.startsWith('Finishing') ? 'Finishing' : session.activityLabel?.startsWith('Waiting for delegates') ? 'Waiting for delegates' : 'Working', tone: 'running', busy: true };
+ return { label: session.activityLabel?.startsWith('Compacting') ? 'Compacting' : session.activityLabel?.startsWith('Cancelling') ? 'Cancelling' : session.activityLabel?.startsWith('Finishing') ? 'Finishing' : session.activityLabel?.startsWith('Waiting for delegates') ? 'Waiting for delegates' : 'Working', tone: 'running', busy: true };
}
if (session.runState === 'completed' || (!session.sessionId && summaryStatus === 'completed')) {
// Same tone the session list gives a completed turn.
diff --git a/src/lib/domain/error-details.test.ts b/src/lib/domain/error-details.test.ts
index e9aae04..14901d4 100644
--- a/src/lib/domain/error-details.test.ts
+++ b/src/lib/domain/error-details.test.ts
@@ -1,4 +1,4 @@
-import { RequestError } from '@agentclientprotocol/sdk';
+import { RequestError } from '@agentclientprotocol/sdk/unstable';
import { describe, expect, it } from 'vitest';
import { describeError } from './error-details';
diff --git a/src/lib/domain/error-details.ts b/src/lib/domain/error-details.ts
index 45adcb8..8ea612e 100644
--- a/src/lib/domain/error-details.ts
+++ b/src/lib/domain/error-details.ts
@@ -1,4 +1,4 @@
-import { RequestError } from '@agentclientprotocol/sdk';
+import { RequestError } from '@agentclientprotocol/sdk/unstable';
export interface ErrorReport {
/** One line for the UI: the error message plus the agent's reason when it gave one. */
diff --git a/src/lib/domain/inbox.test.ts b/src/lib/domain/inbox.test.ts
index 0da09b9..2c4e6b3 100644
--- a/src/lib/domain/inbox.test.ts
+++ b/src/lib/domain/inbox.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import type { CreateElicitationRequest, ElicitationSchema } from '@agentclientprotocol/sdk';
+import type { CreateElicitationRequest, ElicitationSchema } from '@agentclientprotocol/sdk/unstable';
import { buildElicitationResponse, describeElicitationAnswers, mapElicitationRequestToInboxItem } from './inbox';
// Fixtures carry option `description` and `_meta`, which the SDK's EnumOption type does not
@@ -15,6 +15,30 @@ function request(requestedSchema: Record, querymtMeta?: Record<
}
describe('elicitation inbox mapping', () => {
+ it('treats forms with unsupported property types as non-submittable', () => {
+ const item = mapElicitationRequestToInboxItem(
+ request({
+ type: 'object',
+ properties: { blob: { type: 'object', title: 'Blob' }, name: { type: 'string' } },
+ required: ['blob']
+ }),
+ 'item-1'
+ );
+
+ expect(item.formFields).toEqual([]);
+ expect(item.actions?.map((action) => action.kind)).toEqual(['decline', 'cancel']);
+ expect(item.detail).toContain('cannot be submitted');
+ });
+
+ it('refuses to build an accept response for unsupported field kinds', () => {
+ const fields = [
+ { key: 'blob', label: 'Blob', kind: 'object', required: true, value: '' }
+ ] as unknown as Parameters[1];
+
+ expect(buildElicitationResponse('accept', fields)).toBeNull();
+ expect(buildElicitationResponse('decline', fields)).toEqual({ action: 'decline' });
+ });
+
it('maps ACP oneOf and anyOf options', () => {
const item = mapElicitationRequestToInboxItem(
request({
diff --git a/src/lib/domain/inbox.ts b/src/lib/domain/inbox.ts
index 1adf0b4..159a345 100644
--- a/src/lib/domain/inbox.ts
+++ b/src/lib/domain/inbox.ts
@@ -6,7 +6,8 @@ import type {
ElicitationSchema,
PermissionOption,
RequestPermissionRequest
-} from '@agentclientprotocol/sdk';
+} from '@agentclientprotocol/sdk/unstable';
+import { CreateElicitationRequest as ElicitationRequestGuard } from '@agentclientprotocol/sdk/unstable';
import type { InboxAction, InboxFormField, InboxFormOption, InboxItem } from '$lib/domain/types';
import { optionDescriptionsFromMeta } from '$lib/querymt/elicitation-option-meta';
@@ -43,28 +44,38 @@ export function mapElicitationRequestToInboxItem(
agentName?: string
): InboxItem {
const allowCustom = allowsCustomResponses(request);
+ const form = ElicitationRequestGuard.isForm(request) ? request : null;
+ const unsupportedForm = form ? hasUnsupportedProperty(form.requestedSchema) : false;
const formFields =
- request.mode === 'form'
- ? mapElicitationFormFields(request.requestedSchema, allowCustom, optionDescriptionsFromMeta(request._meta))
+ form && !unsupportedForm
+ ? mapElicitationFormFields(form.requestedSchema, allowCustom, optionDescriptionsFromMeta(request._meta))
: [];
- const owner = 'sessionId' in request ? request.sessionId : `request ${String(request.requestId)}`;
- const title = request.mode === 'form' ? request.requestedSchema.title || 'Elicitation request' : 'Elicitation request';
+ const scoped = request as { sessionId?: unknown; requestId?: unknown };
+ const sessionId = typeof scoped.sessionId === 'string' ? scoped.sessionId : null;
+ const owner = sessionId ?? `request ${String(scoped.requestId)}`;
+ const title = form ? form.requestedSchema.title || 'Elicitation request' : 'Elicitation request';
return {
id,
title,
- detail: request.message,
+ detail: unsupportedForm
+ ? `${request.message}\n\nThis form uses field types the desktop app cannot display, so it cannot be submitted here.`
+ : request.message,
owner,
severity: 'medium',
type: 'elicitation',
agentId: agentId ?? null,
agentName: agentName ?? null,
- sessionId: 'sessionId' in request ? request.sessionId : null,
+ sessionId,
status: 'pending',
resolution: null,
error: null,
- actions:
- request.mode === 'form'
+ actions: unsupportedForm
+ ? [
+ { id: 'decline', label: 'Decline', kind: 'decline' },
+ { id: 'cancel', label: 'Cancel', kind: 'cancel' }
+ ]
+ : form
? [
{ id: 'accept', label: 'Submit', kind: 'accept' },
{ id: 'decline', label: 'Decline', kind: 'decline' },
@@ -153,6 +164,11 @@ export function buildElicitationResponse(actionId: string, fields: InboxFormFiel
return null;
}
+ // Never submit a form containing fields we cannot represent faithfully.
+ if (fields.some((field) => !SUPPORTED_FIELD_KINDS.has(field.kind))) {
+ return null;
+ }
+
const content: Record = {};
for (const field of fields) {
if (field.customActive) {
@@ -186,6 +202,16 @@ export function buildElicitationResponse(actionId: string, fields: InboxFormFiel
};
}
+const SUPPORTED_FIELD_KINDS: ReadonlySet = new Set(['string', 'number', 'integer', 'boolean', 'array']);
+
+function hasUnsupportedProperty(schema: ElicitationSchema): boolean {
+ const properties = (schema.properties ?? {}) as Record;
+ return Object.values(properties).some((property) => {
+ const type = (property as { type?: unknown } | null)?.type;
+ return typeof type !== 'string' || !SUPPORTED_FIELD_KINDS.has(type);
+ });
+}
+
function mapPermissionOption(option: PermissionOption): InboxAction {
return {
id: option.optionId,
@@ -204,12 +230,13 @@ function mapElicitationFormFields(
return Object.entries(properties).map(([key, property]) => {
const options = mapPropertyOptions(property, optionDescriptions[key]);
+ const loose = property as LooseProperty;
return {
key,
- label: property.title || key,
- kind: property.type,
+ label: loose.title || key,
+ kind: loose.type as InboxFormField['kind'],
required: required.has(key),
- description: property.description ?? null,
+ description: loose.description ?? null,
options,
value: getDefaultFieldValue(property),
allowCustom: allowCustom && Boolean(options?.length),
@@ -226,10 +253,21 @@ function allowsCustomResponses(request: CreateElicitationRequest): boolean {
return metadata.source === 'builtin:question' || metadata.allow_custom === true || metadata.allowCustom === true;
}
+type LooseProperty = {
+ type: string;
+ title?: string | null;
+ description?: string | null;
+ default?: unknown;
+ oneOf?: TitledElicitationOption[];
+ enum?: string[];
+ items?: unknown;
+};
+
function mapPropertyOptions(
- property: ElicitationPropertySchema,
+ rawProperty: ElicitationPropertySchema,
optionDescriptions?: Record
): InboxFormOption[] | undefined {
+ const property = rawProperty as LooseProperty;
if (property.type === 'string') {
if (property.oneOf?.length) {
return property.oneOf.map((option) => mapTitledOption(option, optionDescriptions?.[option.const]));
@@ -289,18 +327,11 @@ function mapTitledOption(option: TitledElicitationOption, fallback?: string): In
};
}
-function getDefaultFieldValue(property: ElicitationPropertySchema): InboxFormField['value'] {
- if (property.type === 'boolean') {
- return property.default ?? false;
- }
-
- if (property.type === 'number' || property.type === 'integer') {
- return property.default ?? '';
- }
-
- if (property.type === 'array') {
- return property.default ?? [];
- }
-
- return property.default ?? '';
+function getDefaultFieldValue(rawProperty: ElicitationPropertySchema): InboxFormField['value'] {
+ const property = rawProperty as LooseProperty;
+ const fallback = property.default as InboxFormField['value'] | undefined;
+ if (property.type === 'boolean') return fallback ?? false;
+ if (property.type === 'number' || property.type === 'integer') return fallback ?? '';
+ if (property.type === 'array') return fallback ?? [];
+ return fallback ?? '';
}
diff --git a/src/lib/domain/prompt-errors.test.ts b/src/lib/domain/prompt-errors.test.ts
index 1d0b2af..d581330 100644
--- a/src/lib/domain/prompt-errors.test.ts
+++ b/src/lib/domain/prompt-errors.test.ts
@@ -1,4 +1,4 @@
-import { RequestError } from '@agentclientprotocol/sdk';
+import { RequestError } from '@agentclientprotocol/sdk/unstable';
import { describe, expect, it } from 'vitest';
import { formatPromptErrorForClipboard, formatPromptErrorMessage, normalizePromptError } from './prompt-errors';
diff --git a/src/lib/domain/prompt-errors.ts b/src/lib/domain/prompt-errors.ts
index 3dab835..355f1fe 100644
--- a/src/lib/domain/prompt-errors.ts
+++ b/src/lib/domain/prompt-errors.ts
@@ -1,4 +1,4 @@
-import { RequestError } from '@agentclientprotocol/sdk';
+import { RequestError } from '@agentclientprotocol/sdk/unstable';
import type { PromptAttachment } from '$lib/domain/types';
import { toRemoteNodeUpgradeRequired } from '$lib/domain/remote-errors';
diff --git a/src/lib/domain/remote-errors.test.ts b/src/lib/domain/remote-errors.test.ts
index 18aa2b7..d2a50d0 100644
--- a/src/lib/domain/remote-errors.test.ts
+++ b/src/lib/domain/remote-errors.test.ts
@@ -1,4 +1,4 @@
-import { RequestError } from '@agentclientprotocol/sdk';
+import { RequestError } from '@agentclientprotocol/sdk/unstable';
import { describe, expect, it } from 'vitest';
import {
RemoteNodeUpgradeRequiredError,
diff --git a/src/lib/domain/remote-errors.ts b/src/lib/domain/remote-errors.ts
index 3f488c4..d602117 100644
--- a/src/lib/domain/remote-errors.ts
+++ b/src/lib/domain/remote-errors.ts
@@ -1,4 +1,4 @@
-import { RequestError } from '@agentclientprotocol/sdk';
+import { RequestError } from '@agentclientprotocol/sdk/unstable';
/** Error category the agent reports when a remote mesh node runs an incompatible agent. */
export const REMOTE_UPGRADE_REQUIRED_CATEGORY = 'remote_upgrade_required';
diff --git a/src/lib/domain/session-compaction.test.ts b/src/lib/domain/session-compaction.test.ts
new file mode 100644
index 0000000..1b6f7ce
--- /dev/null
+++ b/src/lib/domain/session-compaction.test.ts
@@ -0,0 +1,163 @@
+import { describe, expect, it } from 'vitest';
+import type { SessionNotification } from '@agentclientprotocol/sdk/unstable';
+import { RuntimeAvailability, SessionRuntimePhase } from '$lib/querymt/generated/types';
+import { activeSessionFromLoadResponse } from './session-snapshot';
+import { buildSessionConversation } from './session-conversation';
+import {
+ applySessionNotification,
+ appendSessionReplay,
+ createEmptyActiveSession,
+ projectSessionRuntime
+} from './session-updates';
+import { COMPACTING_LABEL, describeCompaction, isCompactCommand } from './session-compaction';
+
+function note(update: unknown): SessionNotification {
+ return { sessionId: 'session-1', update } as SessionNotification;
+}
+
+const text = (value: string) => ({ type: 'text', text: value });
+
+function snapshot(events: unknown[]) {
+ return { _meta: { 'querymt/sessionLoadSnapshot.v1': { audit: { events } } } };
+}
+
+describe('isCompactCommand', () => {
+ it('matches the native command with or without instructions', () => {
+ expect(isCompactCommand('/compact')).toBe(true);
+ expect(isCompactCommand(' /compact keep the API notes ')).toBe(true);
+ expect(isCompactCommand('/compactify')).toBe(false);
+ expect(isCompactCommand('please /compact')).toBe(false);
+ });
+});
+
+describe('live compaction updates', () => {
+ it('shows a compacting label, accumulates chunks, then settles with the final summary and token drop', () => {
+ let session = createEmptyActiveSession();
+ session.usage.contextUsed = 182_000;
+ session = applySessionNotification(session, note({ sessionUpdate: 'compaction_update', compactionId: 'c1', status: 'in_progress' }));
+ expect(session.activityLabel).toBe(COMPACTING_LABEL);
+ expect(session.runState).toBe('thinking');
+ expect(session.compactions).toMatchObject([{ id: 'c1', status: 'in_progress', tokensBefore: 182_000 }]);
+
+ session = applySessionNotification(session, note({ sessionUpdate: 'compaction_summary_chunk', compactionId: 'c1', content: text('Part one. ') }));
+ session = applySessionNotification(session, note({ sessionUpdate: 'compaction_summary_chunk', compactionId: 'c1', content: text('Part two.') }));
+ expect(session.compactions?.[0].summary).toBe('Part one. Part two.');
+
+ session = applySessionNotification(session, note({ sessionUpdate: 'compaction_update', compactionId: 'c1', status: 'completed', summary: [text('Final summary')] }));
+ expect(session.compactions?.[0]).toMatchObject({ status: 'completed', summary: 'Final summary', awaitingUsage: true });
+ expect(session.activityLabel).toBe('Context compacted.');
+
+ session = applySessionNotification(session, note({ sessionUpdate: 'usage_update', used: 24_000, size: 200_000 }));
+ expect(session.compactions?.[0]).toMatchObject({ tokensAfter: 24_000, awaitingUsage: false });
+ expect(session.usage.contextUsed).toBe(24_000);
+ });
+
+ it('creates the compaction from a first-seen chunk and never reopens a settled one', () => {
+ let session = createEmptyActiveSession();
+ session = applySessionNotification(session, note({ sessionUpdate: 'compaction_summary_chunk', compactionId: 'c2', content: text('early') }));
+ expect(session.compactions?.[0]).toMatchObject({ id: 'c2', status: 'in_progress', summary: 'early' });
+ session = applySessionNotification(session, note({ sessionUpdate: 'compaction_update', compactionId: 'c2', status: 'failed', error: 'boom' }));
+ session = applySessionNotification(session, note({ sessionUpdate: 'compaction_update', compactionId: 'c2', status: 'in_progress' }));
+ session = applySessionNotification(session, note({ sessionUpdate: 'compaction_summary_chunk', compactionId: 'c2', content: text(' late') }));
+ expect(session.compactions?.[0]).toMatchObject({ status: 'failed', error: 'boom', summary: 'early' });
+ expect(describeCompaction(session.compactions![0])).toMatchObject({ tone: 'failed', detail: 'boom' });
+ });
+
+ it('records cancellation without an error and applies the same result during bulk replay', () => {
+ const updates = [
+ note({ sessionUpdate: 'compaction_update', compactionId: 'c3', status: 'in_progress' }),
+ note({ sessionUpdate: 'compaction_update', compactionId: 'c3', status: 'cancelled' })
+ ];
+ const replayed = appendSessionReplay(createEmptyActiveSession(), updates);
+ expect(replayed.compactions).toMatchObject([{ id: 'c3', status: 'cancelled' }]);
+ expect(replayed.activityLabel).toBe('Compaction cancelled.');
+ });
+
+ it('keeps the compacting label while the runtime reports a busy turn', () => {
+ let session = createEmptyActiveSession();
+ session = applySessionNotification(session, note({ sessionUpdate: 'compaction_update', compactionId: 'c4', status: 'in_progress' }));
+ projectSessionRuntime(session, {
+ phase: SessionRuntimePhase.Model,
+ observation: { availability: RuntimeAvailability.Live }
+ } as never);
+ expect(session.activityLabel).toBe(COMPACTING_LABEL);
+
+ session = applySessionNotification(session, note({ sessionUpdate: 'compaction_update', compactionId: 'c4', status: 'completed' }));
+ projectSessionRuntime(session, {
+ phase: SessionRuntimePhase.Model,
+ observation: { availability: RuntimeAvailability.Live }
+ } as never);
+ expect(session.activityLabel).toBe('Agent is working...');
+ });
+});
+
+describe('compaction in loaded history', () => {
+ it('restores identified compactions with summary, token drop and ordering', () => {
+ const session = activeSessionFromLoadResponse('session-1', snapshot([
+ { seq: 1, timestamp: 100, kind: { type: 'llm_request_end', data: { context_tokens: 150_000 } } },
+ { seq: 2, timestamp: 200, kind: { type: 'compaction_start', data: { token_estimate: 150_000, compaction_id: 'c1' } } },
+ { seq: 3, timestamp: 210, kind: { type: 'compaction_summary_chunk', data: { compaction_id: 'c1', content: 'Summary ' } } },
+ { seq: 4, timestamp: 220, kind: { type: 'compaction_end', data: { compaction_id: 'c1', summary: 'Summary text', summary_len: 12, context_tokens: 20_000 } } }
+ ]));
+ expect(session.compactions).toEqual([
+ expect.objectContaining({ id: 'c1', status: 'completed', summary: 'Summary text', tokensBefore: 150_000, tokensAfter: 20_000, eventIndex: 2 })
+ ]);
+ expect(session.usage.contextUsed).toBe(20_000);
+ expect(describeCompaction(session.compactions![0]).detail).toBe('150k \u2192 20k tokens');
+ });
+
+ it('pairs legacy ID-less start and end records and reports failures and cancellations', () => {
+ const session = activeSessionFromLoadResponse('session-1', snapshot([
+ { seq: 5, kind: { type: 'compaction_start', data: { token_estimate: 1 } } },
+ { seq: 6, kind: { type: 'compaction_end', data: { summary: 'legacy', summary_len: 6 } } },
+ { seq: 7, kind: { type: 'compaction_start', data: { token_estimate: 1, compaction_id: 'f1' } } },
+ { seq: 8, kind: { type: 'compaction_failed', data: { compaction_id: 'f1', reason: 'provider down' } } },
+ { seq: 9, kind: { type: 'compaction_start', data: { token_estimate: 1, compaction_id: 'x1' } } },
+ { seq: 10, kind: { type: 'compaction_failed', data: { compaction_id: 'x1', reason: 'cancelled', cancelled: true } } }
+ ]));
+ expect(session.compactions).toMatchObject([
+ { id: 'querymt-compaction-legacy-5', status: 'completed', summary: 'legacy' },
+ { id: 'f1', status: 'failed', error: 'provider down' },
+ { id: 'x1', status: 'cancelled', error: null }
+ ]);
+ });
+
+ it('marks a compaction that never finished as interrupted once the run is known to be over', () => {
+ const session = activeSessionFromLoadResponse('session-1', snapshot([
+ { seq: 1, kind: { type: 'run_started', data: { run_id: 'r1' } } },
+ { seq: 2, kind: { type: 'compaction_start', data: { token_estimate: 1, compaction_id: 'c9' } } },
+ { seq: 3, kind: { type: 'run_completed', data: { run_id: 'r1', outcome: 'failed' } } }
+ ]));
+ expect(session.compactions?.[0]).toMatchObject({ status: 'failed', error: expect.stringContaining('interrupted') });
+ });
+});
+
+describe('compaction in the conversation', () => {
+ it('is its own entry between turns, not part of either turn', () => {
+ const session = activeSessionFromLoadResponse('session-order', snapshot([
+ { seq: 1, kind: { type: 'prompt_received', data: { message_id: 'm1', content: 'first' } } },
+ { seq: 2, kind: { type: 'assistant_message_stored', data: { message_id: 'a1', content: 'reply one' } } },
+ { seq: 3, kind: { type: 'compaction_start', data: { token_estimate: 5, compaction_id: 'c1' } } },
+ { seq: 4, kind: { type: 'compaction_end', data: { compaction_id: 'c1', summary: 'S', summary_len: 1 } } },
+ { seq: 5, kind: { type: 'prompt_received', data: { message_id: 'm2', content: 'second' } } },
+ { seq: 6, kind: { type: 'assistant_message_stored', data: { message_id: 'a2', content: 'reply two' } } }
+ ]));
+ const turns = buildSessionConversation(session, []);
+ expect(turns.map((turn) => (turn.compaction ? 'compaction' : turn.user?.text))).toEqual(['first', 'compaction', 'second']);
+ expect(turns[1]).toMatchObject({ id: 'compaction-c1', settled: true, content: [], forkMessageId: null });
+ expect(turns[0].content).toHaveLength(1);
+ });
+
+ it('is unsettled while compacting so it stays mounted, and updates as the summary arrives', () => {
+ let session = createEmptyActiveSession();
+ session.sessionId = 'session-1';
+ session = applySessionNotification(session, note({ sessionUpdate: 'compaction_update', compactionId: 'c1', status: 'in_progress' }));
+ let turns = buildSessionConversation(session, []);
+ expect(turns).toHaveLength(1);
+ expect(turns[0]).toMatchObject({ settled: false, compaction: { status: 'in_progress' } });
+
+ session = applySessionNotification(session, note({ sessionUpdate: 'compaction_update', compactionId: 'c1', status: 'completed', summary: [text('Done')] }));
+ turns = buildSessionConversation(session, turns);
+ expect(turns[0]).toMatchObject({ settled: true, compaction: { status: 'completed', summary: 'Done' } });
+ });
+});
diff --git a/src/lib/domain/session-compaction.ts b/src/lib/domain/session-compaction.ts
new file mode 100644
index 0000000..cd7ac9c
--- /dev/null
+++ b/src/lib/domain/session-compaction.ts
@@ -0,0 +1,175 @@
+import type { ActiveSessionViewModel, SessionCompactionItem } from '$lib/domain/types';
+
+export const COMPACTING_LABEL = 'Compacting context\u2026';
+
+type ContentLike = { type?: string; text?: string };
+
+/** Shape shared by ACP `compaction_update` notifications (kept structural to stay SDK-agnostic). */
+export interface CompactionUpdateInput {
+ compactionId: string;
+ status: string;
+ summary?: ContentLike[] | null;
+ error?: string | null;
+}
+
+export interface CompactionChunkInput {
+ compactionId: string;
+ content: ContentLike;
+}
+
+/** True for the native `/compact [instructions]` command. */
+export function isCompactCommand(prompt: string): boolean {
+ return /^\/compact(\s|$)/i.test(prompt.trim());
+}
+
+export function hasActiveCompaction(session: Pick): boolean {
+ return session.compactions?.some((item) => item.status === 'in_progress') ?? false;
+}
+
+export function isCompactingLabel(label: string | null | undefined): boolean {
+ return Boolean(label?.startsWith('Compacting'));
+}
+
+function normalizeStatus(status: string): SessionCompactionItem['status'] {
+ return status === 'completed' || status === 'failed' || status === 'cancelled' ? status : 'in_progress';
+}
+
+function blocksToText(blocks: ContentLike[]): string {
+ return blocks.map((block) => (block?.type === 'text' ? (block.text ?? '') : '')).join('');
+}
+
+function ensure(
+ session: ActiveSessionViewModel,
+ id: string,
+ eventIndex: number | undefined,
+ now: number
+): SessionCompactionItem {
+ const list = (session.compactions ??= []);
+ let item = list.find((entry) => entry.id === id);
+ if (!item) {
+ const before = session.usage.contextUsed;
+ item = {
+ id,
+ status: 'in_progress',
+ summary: '',
+ eventIndex,
+ startedAtMs: now,
+ tokensBefore: typeof before === 'number' ? before : null
+ };
+ list.push(item);
+ }
+ return item;
+}
+
+function finish(session: ActiveSessionViewModel, item: SessionCompactionItem, now: number) {
+ item.endedAtMs = now;
+ if (!isCompactingLabel(session.activityLabel)) return;
+ session.activityLabel =
+ item.status === 'completed' ? 'Context compacted.'
+ : item.status === 'cancelled' ? 'Compaction cancelled.'
+ : 'Compaction failed.';
+}
+
+/**
+ * Upserts a compaction from a live `compaction_update`. `summary` and `error`
+ * have patch semantics: omitted keeps the value, null or `[]` clears it.
+ * Mutates `session`; callers hand in an already-cloned view model.
+ */
+export function applyCompactionUpdate(
+ session: ActiveSessionViewModel,
+ update: CompactionUpdateInput,
+ eventIndex: number,
+ now = Date.now()
+): void {
+ const item = ensure(session, update.compactionId, eventIndex, now);
+ const status = normalizeStatus(update.status);
+ if (item.status !== 'in_progress' && status === 'in_progress') return; // never reopen a settled compaction
+ item.status = status;
+ if (update.summary !== undefined) item.summary = update.summary ? blocksToText(update.summary) : '';
+ if (update.error !== undefined) item.error = update.error;
+ if (status === 'in_progress') {
+ session.runState = 'thinking';
+ session.activityLabel = COMPACTING_LABEL;
+ session.lastError = null;
+ return;
+ }
+ item.awaitingUsage = status === 'completed';
+ finish(session, item, now);
+}
+
+/** Appends one summary fragment; a first-seen ID creates an in-progress compaction. */
+export function applyCompactionSummaryChunk(
+ session: ActiveSessionViewModel,
+ chunk: CompactionChunkInput,
+ eventIndex: number,
+ now = Date.now()
+): void {
+ const item = ensure(session, chunk.compactionId, eventIndex, now);
+ if (item.status !== 'in_progress') return;
+ item.summary += chunk.content?.type === 'text' ? (chunk.content.text ?? '') : '';
+ session.runState = 'thinking';
+ session.activityLabel = COMPACTING_LABEL;
+}
+
+/**
+ * The usage update right after a completed compaction carries its
+ * post-compaction context size; any other update ends that window.
+ */
+export function settleCompactionUsage(session: ActiveSessionViewModel, used: number | null): void {
+ for (const item of session.compactions ?? []) {
+ if (!item.awaitingUsage) continue;
+ item.awaitingUsage = false;
+ if (used !== null) item.tokensAfter = used;
+ }
+}
+
+export function clearAwaitingCompactionUsage(session: ActiveSessionViewModel): void {
+ settleCompactionUsage(session, null);
+}
+
+export function cloneCompactions(items: SessionCompactionItem[] | undefined): SessionCompactionItem[] | undefined {
+ return items?.map((item) => ({ ...item }));
+}
+
+/** A load is not evidence a compaction ended; once the run is known to be over it cannot still be running. */
+export function interruptOpenCompactions(session: ActiveSessionViewModel): void {
+ for (const item of session.compactions ?? []) {
+ if (item.status !== 'in_progress') continue;
+ item.status = 'failed';
+ item.error = item.error ?? 'Compaction was interrupted before it finished.';
+ }
+}
+
+function formatTokens(value: number): string {
+ if (value < 1000) return String(Math.round(value));
+ const thousands = value / 1000;
+ return `${thousands >= 100 ? Math.round(thousands) : Math.round(thousands * 10) / 10}k`;
+}
+
+export interface CompactionPresentation {
+ title: string;
+ detail: string | null;
+ tone: 'running' | 'done' | 'failed' | 'cancelled';
+}
+
+export function describeCompaction(item: SessionCompactionItem): CompactionPresentation {
+ const before = typeof item.tokensBefore === 'number' ? item.tokensBefore : null;
+ const after = typeof item.tokensAfter === 'number' ? item.tokensAfter : null;
+ switch (item.status) {
+ case 'in_progress':
+ return { title: COMPACTING_LABEL, detail: before !== null ? `${formatTokens(before)} tokens in context` : null, tone: 'running' };
+ case 'failed':
+ return { title: 'Compaction failed', detail: item.error ?? null, tone: 'failed' };
+ case 'cancelled':
+ return { title: 'Compaction cancelled', detail: null, tone: 'cancelled' };
+ default:
+ return {
+ title: 'Context compacted',
+ detail:
+ before !== null && after !== null ? `${formatTokens(before)} \u2192 ${formatTokens(after)} tokens`
+ : after !== null ? `${formatTokens(after)} tokens remaining`
+ : null,
+ tone: 'done'
+ };
+ }
+}
diff --git a/src/lib/domain/session-conversation.ts b/src/lib/domain/session-conversation.ts
index 7847448..738d52c 100644
--- a/src/lib/domain/session-conversation.ts
+++ b/src/lib/domain/session-conversation.ts
@@ -5,6 +5,7 @@ import { getSessionToolChangeStats, isDelegateTool, type SessionToolChangeStats
import { getTranscriptBlocks, mapTranscriptRole } from '$lib/domain/session-updates';
import type {
ActiveSessionViewModel,
+ SessionCompactionItem,
SessionContentBlock,
SessionToolCallItem,
SessionTranscriptGroup,
@@ -73,15 +74,19 @@ export type SessionConversationTurn = {
content: SessionConversationContent[];
presentation?: SessionConversationPresentationItem[];
settled?: boolean;
+ /** Set when this entry is a context-compaction boundary rather than a prompt/response turn. */
+ compaction?: SessionCompactionItem;
};
type RawConversationItem =
| { type: 'transcript'; transcript: SessionTranscriptItem; eventIndex?: number; sourceOrder: number }
- | { type: 'tool'; tool: SessionToolCallItem; eventIndex?: number; sourceOrder: number };
+ | { type: 'tool'; tool: SessionToolCallItem; eventIndex?: number; sourceOrder: number }
+ | { type: 'compaction'; compaction: SessionCompactionItem; eventIndex?: number; sourceOrder: number };
type OrderedConversationItem =
| { type: 'group'; group: SessionTranscriptGroup }
- | { type: 'tool'; tool: SessionToolCallItem };
+ | { type: 'tool'; tool: SessionToolCallItem }
+ | { type: 'compaction'; compaction: SessionCompactionItem };
type DraftContent =
| SessionToolContent
@@ -100,6 +105,7 @@ type DraftTurn = {
};
content: DraftContent[];
durationMs?: number;
+ compaction?: SessionCompactionItem;
};
type ConversationCache = {
@@ -134,7 +140,8 @@ function conversationRevision(session: ActiveSessionViewModel): string {
lastTool?.arguments ?? '',
lastTool?.messageId ?? '',
String(lastTool?.isError ?? ''),
- String(lastTool?.eventIndex ?? '')
+ String(lastTool?.eventIndex ?? ''),
+ JSON.stringify(session.compactions ?? [])
].join('\0');
}
@@ -172,8 +179,11 @@ function materializeConversation(
const liveReasoning = session.runState === 'thinking';
return drafts.map((draft, index) => {
- const settled = index !== activeTurnIndex;
+ const settled = draft.compaction ? draft.compaction.status !== 'in_progress' : index !== activeTurnIndex;
const previous = previousTurns?.[index];
+ if (draft.compaction) {
+ return { id: draft.id, forkMessageId: null, content: [], settled, compaction: draft.compaction, presentation: [] };
+ }
if (previous && canReuseSettledTurn(previous, draft, settled)) {
return previous;
}
@@ -226,6 +236,7 @@ function sameBlocks(left?: SessionContentBlock[], right?: SessionContentBlock[])
function canReuseSettledTurn(previous: SessionConversationTurn, draft: DraftTurn, settled: boolean): boolean {
if (!settled || !previous.settled) return false;
+ if (previous.compaction || draft.compaction) return false;
if (previous.id !== draft.id) return false;
if (previous.forkMessageId !== draft.forkMessageId) return false;
if ((previous.durationMs ?? undefined) !== (draft.durationMs ?? undefined)) return false;
@@ -293,12 +304,20 @@ function materializeContent(
}
function buildConversationDrafts(session: ActiveSessionViewModel): DraftTurn[] {
- const orderedItems = buildOrderedItems(session.transcript, canonicalizeTools(session.toolCalls));
+ const orderedItems = buildOrderedItems(session.transcript, canonicalizeTools(session.toolCalls), session.compactions ?? []);
const turns: DraftTurn[] = [];
const turnTimings: Array<{ started?: number; ended?: number }> = [];
let current: DraftTurn | null = null;
for (const item of orderedItems) {
+ if (item.type === 'compaction') {
+ // A boundary of its own; whatever follows starts a fresh turn.
+ turns.push({ id: `compaction-${item.compaction.id}`, forkMessageId: null, content: [], compaction: item.compaction });
+ turnTimings.push({});
+ current = null;
+ continue;
+ }
+
if (item.type === 'group' && item.group.role === 'user') {
current = {
id: `turn-${item.group.id}`,
@@ -362,7 +381,7 @@ function buildConversationDrafts(session: ActiveSessionViewModel): DraftTurn[] {
}
});
- return turns.filter((turn) => turn.user || turn.content.length > 0);
+ return turns.filter((turn) => turn.compaction || turn.user || turn.content.length > 0);
}
export function getTurnDelegates(turn: SessionConversationTurn | undefined): SessionToolCallItem[] {
@@ -492,7 +511,11 @@ function sameThoughtPart(group: SessionTranscriptGroup, item: SessionTranscriptI
return incoming === current;
}
-function buildOrderedItems(transcript: SessionTranscriptItem[], tools: SessionToolCallItem[]): OrderedConversationItem[] {
+function buildOrderedItems(
+ transcript: SessionTranscriptItem[],
+ tools: SessionToolCallItem[],
+ compactions: SessionCompactionItem[]
+): OrderedConversationItem[] {
const rawItems: RawConversationItem[] = [
...transcript.map((item, sourceOrder) => ({
type: 'transcript' as const,
@@ -505,6 +528,12 @@ function buildOrderedItems(transcript: SessionTranscriptItem[], tools: SessionTo
tool,
eventIndex: tool.eventIndex,
sourceOrder: transcript.length + index
+ })),
+ ...compactions.map((compaction, index) => ({
+ type: 'compaction' as const,
+ compaction,
+ eventIndex: compaction.eventIndex,
+ sourceOrder: transcript.length + tools.length + index
}))
].sort(compareRawItems);
@@ -512,6 +541,12 @@ function buildOrderedItems(transcript: SessionTranscriptItem[], tools: SessionTo
let openAssistantGroup: SessionTranscriptGroup | null = null;
let openThoughtGroup: SessionTranscriptGroup | null = null;
for (const item of rawItems) {
+ if (item.type === 'compaction') {
+ openAssistantGroup = null;
+ openThoughtGroup = null;
+ orderedItems.push({ type: 'compaction', compaction: item.compaction });
+ continue;
+ }
if (item.type === 'tool') {
// A tool call ends both the current assistant text segment and the
// current reasoning trace: what streams after it starts fresh.
diff --git a/src/lib/domain/session-mode-tone.test.ts b/src/lib/domain/session-mode-tone.test.ts
index ab19723..323d341 100644
--- a/src/lib/domain/session-mode-tone.test.ts
+++ b/src/lib/domain/session-mode-tone.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import type { SessionConfigOption, SessionConfigSelectGroup, SessionConfigSelectOption } from '@agentclientprotocol/sdk';
+import type { SessionConfigOption, SessionConfigSelectGroup, SessionConfigSelectOption } from '@agentclientprotocol/sdk/unstable';
import { getModeToneIndex, getSessionContextTone, SESSION_CONTEXT_TONE_COUNT } from './session-mode-tone';
function modeOption(values: string[], currentValue?: string): SessionConfigOption {
diff --git a/src/lib/domain/session-mode-tone.ts b/src/lib/domain/session-mode-tone.ts
index bd1db9d..3ebcb74 100644
--- a/src/lib/domain/session-mode-tone.ts
+++ b/src/lib/domain/session-mode-tone.ts
@@ -1,4 +1,4 @@
-import type { SessionConfigOption } from '@agentclientprotocol/sdk';
+import type { SessionConfigOption } from '@agentclientprotocol/sdk/unstable';
import { findModeConfigOption, getConfigOptionChoices } from '$lib/querymt/config-options';
/** Size of the mode tone palette defined in app.css (`--mode-tone-*`). */
diff --git a/src/lib/domain/session-snapshot.ts b/src/lib/domain/session-snapshot.ts
index e35680a..7cb029a 100644
--- a/src/lib/domain/session-snapshot.ts
+++ b/src/lib/domain/session-snapshot.ts
@@ -1,5 +1,7 @@
import { applyDelegationChildSession, createEmptyActiveSession, normalizeContentBlocks, stringifyOptional, summarizeContentBlocks } from '$lib/domain/session-updates';
import type { ActiveSessionViewModel, PromptAttachment, SessionContentBlock, SessionToolCallItem, SessionTranscriptItem } from '$lib/domain/types';
+import { interruptOpenCompactions } from '$lib/domain/session-compaction';
+import type { SessionCompactionItem } from '$lib/domain/types';
import { appendHistoricalUsage, readAccountingSnapshot, readTokenUsage } from '$lib/domain/session-usage';
import type { DelegationUpdateNotification, HistoryInfo, SessionHistorySummary, SessionRuntimeStatus, TruncatedEntry } from '$lib/querymt/generated/types';
@@ -92,6 +94,8 @@ export function activeSessionFromLoadResponse(sessionId: string, response: unkno
const windowSummary = readWindowSummary(snapshot);
const toolCallsById = new Map();
+ const compactionsById = new Map();
+ let openLegacyCompactionId: string | null = null;
const supersededRunIds = new Set();
const pendingDelegationToolCalls = new Map();
const pendingChildSessionsByDelegation = new Map();
@@ -131,6 +135,51 @@ export function activeSessionFromLoadResponse(sessionId: string, response: unkno
continue;
}
+ if (kind === 'compaction_start' || kind === 'compaction_summary_chunk' || kind === 'compaction_end' || kind === 'compaction_failed') {
+ const seq = event.seq ?? session.events.length + 1;
+ let id = readString(data.compaction_id);
+ if (!id) {
+ // Legacy records carry no identity: pair terminals with the latest open start.
+ if (kind === 'compaction_start' || !openLegacyCompactionId) openLegacyCompactionId = `querymt-compaction-legacy-${seq}`;
+ id = openLegacyCompactionId;
+ }
+ const timestampMs = readTimestampMs(event.timestamp) ?? undefined;
+ let item = compactionsById.get(id);
+ if (!item) {
+ item = {
+ id,
+ status: 'in_progress',
+ summary: '',
+ eventIndex: event.seq,
+ startedAtMs: timestampMs,
+ // The reduced count is only known from the end event; the "before" is the count at start.
+ tokensBefore: session.usage.contextUsed
+ };
+ compactionsById.set(id, item);
+ }
+ if (kind === 'compaction_summary_chunk') {
+ item.summary += readString(data.content) ?? '';
+ } else if (kind === 'compaction_end') {
+ item.status = 'completed';
+ item.summary = readString(data.summary) ?? item.summary;
+ item.endedAtMs = timestampMs;
+ const after = readNonNegativeNumber(data.context_tokens);
+ if (after !== null) {
+ item.tokensAfter = after;
+ session.usage.contextUsed = after;
+ }
+ if (!readString(data.compaction_id)) openLegacyCompactionId = null;
+ } else if (kind === 'compaction_failed') {
+ const cancelled = readBoolean(data.cancelled);
+ item.status = cancelled ? 'cancelled' : 'failed';
+ item.error = cancelled ? null : readString(data.reason) ?? null;
+ item.endedAtMs = timestampMs;
+ if (!readString(data.compaction_id)) openLegacyCompactionId = null;
+ }
+ session.events.push({ id: `snapshot-${seq}`, kind, text: '', messageId: null });
+ continue;
+ }
+
if (kind === 'cancelled' || kind === 'error') {
session.usage.activeWorkMs += elapsedWorkMs(activeWorkStartedAt, readTimestampMs(event.timestamp));
activeWorkStartedAt = null;
@@ -311,6 +360,7 @@ export function activeSessionFromLoadResponse(sessionId: string, response: unkno
}
}
+ if (compactionsById.size > 0) session.compactions = [...compactionsById.values()];
restoreUnmatchedPrompts(session, snapshot, structuredPrompts, restoredStructuredPrompts, toolCallsById);
applyTruncation(toolCallsById, snapshot.truncated);
if (windowSummary) {
@@ -362,7 +412,12 @@ export function prependOlderHistory(
}),
...current.toolCalls
],
- events: [...older.events.filter((event) => !eventIds.has(event.id)), ...current.events]
+ events: [...older.events.filter((event) => !eventIds.has(event.id)), ...current.events],
+ compactions: (() => {
+ const known = new Set((current.compactions ?? []).map((item) => item.id));
+ const merged = [...(older.compactions ?? []).filter((item) => !known.has(item.id)), ...(current.compactions ?? [])];
+ return merged.length > 0 ? merged : undefined;
+ })()
};
}
@@ -498,6 +553,7 @@ export function normalizeHistoricalSession(
): ActiveSessionViewModel {
// Loading history is not evidence that execution or a pending tool ended.
if (session.runStateFromLifecycle && (session.runState === 'completed' || session.runState === 'failed')) {
+ interruptOpenCompactions(session);
session.activeToolCallId = null;
session.toolCalls = session.toolCalls.map((tool) =>
(tool.status === 'pending' || tool.status === 'in_progress') && (!tool.runId || tool.runId === session.runId)
diff --git a/src/lib/domain/session-updates.replay.test.ts b/src/lib/domain/session-updates.replay.test.ts
index c5b0cd3..c74ebf5 100644
--- a/src/lib/domain/session-updates.replay.test.ts
+++ b/src/lib/domain/session-updates.replay.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import type { SessionNotification } from '@agentclientprotocol/sdk';
+import type { SessionNotification } from '@agentclientprotocol/sdk/unstable';
import { applySessionNotification, createEmptyActiveSession, reduceSessionReplay } from './session-updates';
import { createSyntheticSessionLoadFixture } from '$lib/perf/session-load-fixture';
diff --git a/src/lib/domain/session-updates.test.ts b/src/lib/domain/session-updates.test.ts
index 6794645..4656f3b 100644
--- a/src/lib/domain/session-updates.test.ts
+++ b/src/lib/domain/session-updates.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import type { SessionNotification } from '@agentclientprotocol/sdk';
+import type { SessionNotification } from '@agentclientprotocol/sdk/unstable';
import type { AccountingSnapshot } from '$lib/querymt/generated/types';
import { activeSessionFromLoadResponse } from './session-snapshot';
import { SessionRuntimePhase, DelegationUpdateState, DelegationActivity, DelegationStage, RuntimeAvailability } from '$lib/querymt/generated/types';
diff --git a/src/lib/domain/session-updates.ts b/src/lib/domain/session-updates.ts
index decc278..72b75cf 100644
--- a/src/lib/domain/session-updates.ts
+++ b/src/lib/domain/session-updates.ts
@@ -1,6 +1,15 @@
-import type { SessionNotification } from '@agentclientprotocol/sdk';
+import type { SessionNotification } from '@agentclientprotocol/sdk/unstable';
import { DelegationActivity, type DelegationUpdateNotification, type SessionRuntimeStatus } from '$lib/querymt/generated/types';
import { readAccountingMeta, readUsageBreakdown } from '$lib/domain/session-usage';
+import {
+ applyCompactionSummaryChunk,
+ applyCompactionUpdate,
+ cloneCompactions,
+ COMPACTING_LABEL,
+ hasActiveCompaction,
+ isCompactingLabel,
+ settleCompactionUsage
+} from '$lib/domain/session-compaction';
import type {
ActiveSessionViewModel,
SessionEventItem,
@@ -90,6 +99,9 @@ function currentConversationEventIndex(session: ActiveSessionViewModel): number
for (const tool of session.toolCalls) {
if (typeof tool.eventIndex === 'number') maxIndex = Math.max(maxIndex, tool.eventIndex);
}
+ for (const item of session.compactions ?? []) {
+ if (typeof item.eventIndex === 'number') maxIndex = Math.max(maxIndex, item.eventIndex);
+ }
return maxIndex + 1;
}
@@ -252,6 +264,13 @@ function applySessionNotificationInPlace(
session.usage.breakdown = readUsageBreakdown(update._meta) ?? session.usage.breakdown;
session.usage.standardCostUsd = readCostUsd(update.cost) ?? session.usage.standardCostUsd;
applyAccountingMeta(session, update._meta);
+ settleCompactionUsage(session, readFiniteNumber(update.used));
+ break;
+ case 'compaction_update':
+ applyCompactionUpdate(session, update, conversationEventIndex);
+ break;
+ case 'compaction_summary_chunk':
+ applyCompactionSummaryChunk(session, update, conversationEventIndex);
break;
case 'session_info_update':
// Metadata-only accounting fallback when no reliable context pair exists.
@@ -280,6 +299,9 @@ export function getNextConversationEventIndex(session: ActiveSessionViewModel):
for (const tool of session.toolCalls) {
if (typeof tool.eventIndex === 'number') maxIndex = Math.max(maxIndex, tool.eventIndex);
}
+ for (const item of session.compactions ?? []) {
+ if (typeof item.eventIndex === 'number') maxIndex = Math.max(maxIndex, item.eventIndex);
+ }
return maxIndex + 1;
}
@@ -290,6 +312,7 @@ function cloneSession(current: ActiveSessionViewModel): ActiveSessionViewModel {
...current,
transcript: current.transcript.map((item) => ({ ...item, blocks: item.blocks?.map((block) => ({ ...block })) })),
toolCalls: current.toolCalls.map((item) => ({ ...item })),
+ compactions: cloneCompactions(current.compactions),
plans: current.plans.map((item) => ({ ...item })),
events: current.events.map((item) => ({ ...item })),
configOptions: current.configOptions.map((item) => ({ ...item })),
@@ -507,6 +530,13 @@ export function applySessionNotification(
next.usage.breakdown = readUsageBreakdown(update._meta) ?? next.usage.breakdown;
next.usage.standardCostUsd = readCostUsd(update.cost) ?? next.usage.standardCostUsd;
applyAccountingMeta(next, update._meta);
+ settleCompactionUsage(next, readFiniteNumber(update.used));
+ break;
+ case 'compaction_update':
+ applyCompactionUpdate(next, update, conversationEventIndex);
+ break;
+ case 'compaction_summary_chunk':
+ applyCompactionSummaryChunk(next, update, conversationEventIndex);
break;
case 'session_info_update':
applyAccountingMeta(next, update._meta);
@@ -561,6 +591,12 @@ export function projectSessionRuntime(session: ActiveSessionViewModel, runtime:
}
beginSessionWork(session);
session.lastError = null;
+ if (hasActiveCompaction(session) || (isCompactingLabel(session.activityLabel) && runtime.phase !== 'cancel_requested' && runtime.phase !== 'closing')) {
+ // The runtime only knows the turn is busy; the compaction knows why.
+ session.runState = 'thinking';
+ session.activityLabel = COMPACTING_LABEL;
+ return;
+ }
const responding = runtime.phase === 'model' && session.runState === 'streaming' && previousRunId === session.runId;
session.runState = runtime.phase === 'tools' ? 'tool-running' : responding ? 'streaming' : 'thinking';
const active = getActiveSessionTools(session);
@@ -1090,6 +1126,10 @@ function summarizeUpdate(notification: SessionNotification): string {
const cost = readCostUsd(update.cost);
return `Context ${update.used}/${update.size} tokens${percent}${cost !== null ? ` · $${cost.toFixed(4)}` : ''}`;
}
+ case 'compaction_update':
+ return `Compaction ${update.status}`;
+ case 'compaction_summary_chunk':
+ return 'Compaction summary';
case 'available_commands_update':
return 'Available commands update';
case 'current_mode_update':
diff --git a/src/lib/domain/session-workspace.test.ts b/src/lib/domain/session-workspace.test.ts
index 873ca14..05564c5 100644
--- a/src/lib/domain/session-workspace.test.ts
+++ b/src/lib/domain/session-workspace.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { RequestError } from '@agentclientprotocol/sdk';
+import { RequestError } from '@agentclientprotocol/sdk/unstable';
import {
buildSessionWorkspaceRequest,
describeSessionAccess,
diff --git a/src/lib/domain/session-workspace.ts b/src/lib/domain/session-workspace.ts
index df36341..83ed414 100644
--- a/src/lib/domain/session-workspace.ts
+++ b/src/lib/domain/session-workspace.ts
@@ -1,4 +1,4 @@
-import { RequestError } from '@agentclientprotocol/sdk';
+import { RequestError } from '@agentclientprotocol/sdk/unstable';
/**
* Managed Git worktree support (agent change `add-git-worktree-isolation`).
diff --git a/src/lib/domain/sessions.test.ts b/src/lib/domain/sessions.test.ts
index af64e74..91be9ab 100644
--- a/src/lib/domain/sessions.test.ts
+++ b/src/lib/domain/sessions.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import type { SessionInfo } from '@agentclientprotocol/sdk';
+import type { SessionInfo } from '@agentclientprotocol/sdk/unstable';
import { buildListSessionChildrenRequest, getSessionGroupCwd, compareSessionsByPin, readSessionPin, sessionMatchesLifecycle, buildListSessionsRequest, formatSessionTimestamp, formatSessionTimestampShort, getRecentSessionRailItems, getSessionStatusTone, groupSessionsByWorkspace, inferSessionStatus, isRootSession, mapAcpSessionsToDesktopSessions, newestTimestamp } from './sessions';
import type { DesktopSessionSummary, SessionStatus } from './types';
diff --git a/src/lib/domain/sessions.ts b/src/lib/domain/sessions.ts
index f66a06d..15266ed 100644
--- a/src/lib/domain/sessions.ts
+++ b/src/lib/domain/sessions.ts
@@ -1,4 +1,4 @@
-import type { ListSessionsRequest, SessionInfo } from '@agentclientprotocol/sdk';
+import type { ListSessionsRequest, SessionInfo } from '@agentclientprotocol/sdk/unstable';
import type { DesktopSessionSummary, SessionBrowserState, SessionPin, SessionLifecycle, SessionLifecycleFilter, SessionLifecycleView, SessionStatus } from '$lib/domain/types';
import { readSessionAccess, readSessionWorkspace } from '$lib/domain/session-workspace';
import {
diff --git a/src/lib/domain/types.ts b/src/lib/domain/types.ts
index 9ba9f72..066ff6c 100644
--- a/src/lib/domain/types.ts
+++ b/src/lib/domain/types.ts
@@ -1,4 +1,4 @@
-import type { SessionConfigOption } from '@agentclientprotocol/sdk';
+import type { SessionConfigOption } from '@agentclientprotocol/sdk/unstable';
import type { AccountingSnapshot, DelegationUpdateNotification, DelegationUpdateState } from '$lib/querymt/generated/types';
export type RuntimeStatus = 'running' | 'starting' | 'degraded' | 'stopped';
@@ -318,6 +318,25 @@ export interface SessionTranscriptGroup {
endedAtMs?: number;
}
+/** One context compaction. Rendered as a boundary in the conversation, never as a turn. */
+export interface SessionCompactionItem {
+ /** Agent-owned ID; legacy records without one use `querymt-compaction-legacy-`. */
+ id: string;
+ status: 'in_progress' | 'completed' | 'failed' | 'cancelled';
+ /** Retained summary text; chunks append and a terminal summary replaces it. */
+ summary: string;
+ error?: string | null;
+ eventIndex?: number;
+ startedAtMs?: number;
+ endedAtMs?: number;
+ /** Context tokens in use when the compaction started, when known. */
+ tokensBefore?: number | null;
+ /** Context tokens after the compaction, when the agent reported it. */
+ tokensAfter?: number | null;
+ /** Live only: the next usage update is this compaction's post-compaction count. */
+ awaitingUsage?: boolean;
+}
+
export interface SessionToolCallItem {
id: string;
runId?: string | null;
@@ -391,6 +410,7 @@ export interface ActiveSessionViewModel {
sessionId: string | null;
transcript: SessionTranscriptItem[];
toolCalls: SessionToolCallItem[];
+ compactions?: SessionCompactionItem[];
plans: SessionPlanEntry[];
events: SessionEventItem[];
configOptions: SessionConfigOption[];
diff --git a/src/lib/perf/session-catalog-fixture.ts b/src/lib/perf/session-catalog-fixture.ts
index a457ba1..4e3ea40 100644
--- a/src/lib/perf/session-catalog-fixture.ts
+++ b/src/lib/perf/session-catalog-fixture.ts
@@ -1,4 +1,4 @@
-import type { ListSessionsRequest, ListSessionsResponse, SessionInfo } from '@agentclientprotocol/sdk';
+import type { ListSessionsRequest, ListSessionsResponse, SessionInfo } from '@agentclientprotocol/sdk/unstable';
import { AgentsStore } from '$lib/stores/agents.svelte';
import type { SessionLifecycleFilter } from '$lib/domain/types';
diff --git a/src/lib/perf/session-load-fixture.ts b/src/lib/perf/session-load-fixture.ts
index 0f2bfdb..db7c58c 100644
--- a/src/lib/perf/session-load-fixture.ts
+++ b/src/lib/perf/session-load-fixture.ts
@@ -1,4 +1,4 @@
-import type { LoadSessionResponse, SessionNotification } from '@agentclientprotocol/sdk';
+import type { LoadSessionResponse, SessionNotification } from '@agentclientprotocol/sdk/unstable';
export const TRACE_SESSION_LOAD_1X = {
events: 4_189,
diff --git a/src/lib/perf/session-load-pipeline.ts b/src/lib/perf/session-load-pipeline.ts
index ca64302..ded97d3 100644
--- a/src/lib/perf/session-load-pipeline.ts
+++ b/src/lib/perf/session-load-pipeline.ts
@@ -1,4 +1,4 @@
-import type { LoadSessionResponse, SessionNotification } from '@agentclientprotocol/sdk';
+import type { LoadSessionResponse, SessionNotification } from '@agentclientprotocol/sdk/unstable';
import type { ActiveSessionViewModel } from '$lib/domain/types';
import { activeSessionFromLoadResponse, normalizeHistoricalSession } from '$lib/domain/session-snapshot';
import { applySessionNotification, createEmptyActiveSession } from '$lib/domain/session-updates';
diff --git a/src/lib/platform/desktop-native.ts b/src/lib/platform/desktop-native.ts
index f7f39ef..1f20742 100644
--- a/src/lib/platform/desktop-native.ts
+++ b/src/lib/platform/desktop-native.ts
@@ -1,5 +1,5 @@
-import type { Stream } from '@agentclientprotocol/sdk';
-import type { AnyMessage } from '@agentclientprotocol/sdk/dist/jsonrpc.js';
+import type { Stream } from '@agentclientprotocol/sdk/unstable';
+import type { AnyMessage } from '@agentclientprotocol/sdk/unstable';
export { createTauriAcpStream } from '$lib/querymt/transport-tauri';
diff --git a/src/lib/platform/embedded-native.ts b/src/lib/platform/embedded-native.ts
index 5e38e03..137503c 100644
--- a/src/lib/platform/embedded-native.ts
+++ b/src/lib/platform/embedded-native.ts
@@ -1,4 +1,4 @@
-import type { Stream } from '@agentclientprotocol/sdk';
+import type { Stream } from '@agentclientprotocol/sdk/unstable';
import type { AgentConfig, AgentRuntimeStatus, WorkspaceSuggestion } from '$lib/domain/types';
import type { SessionLoadTelemetryCounters } from '$lib/perf/session-load-metrics';
export type { SessionLoadTelemetryCounters } from '$lib/perf/session-load-metrics';
diff --git a/src/lib/querymt/acp-client.test.ts b/src/lib/querymt/acp-client.test.ts
index 0a76b5e..9555a02 100644
--- a/src/lib/querymt/acp-client.test.ts
+++ b/src/lib/querymt/acp-client.test.ts
@@ -1,9 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { RequestError } from '@agentclientprotocol/sdk';
+import { RequestError } from '@agentclientprotocol/sdk/unstable';
import { DesktopAcpClient, clearElicitationResumeAuthorities, clearElicitationResumeAuthoritiesForAgent } from './acp-client';
import { RemoteNodeUpgradeRequiredError } from '$lib/domain/remote-errors';
import type { QuerymtExtensionNotification } from './querymt-extensions';
-import type { AnyMessage } from '@agentclientprotocol/sdk/dist/jsonrpc.js';
+import type { AnyMessage } from '@agentclientprotocol/sdk/unstable';
const beginSessionReplay = vi.fn(() => ({ sessionId: 'session-1', notifications: [] }));
const completeSessionReplay = vi.fn(() => []);
@@ -15,6 +15,11 @@ vi.mock('./browser-client', () => ({
beginSessionReplay = beginSessionReplay;
completeSessionReplay = completeSessionReplay;
abortSessionReplay = abortSessionReplay;
+ async extNotification(method: string, params: unknown) {
+ for (const handler of [...extensionNotificationHandlers]) {
+ handler({ method, params } as QuerymtExtensionNotification);
+ }
+ }
onExtensionNotification(handler: (notification: QuerymtExtensionNotification) => void) {
extensionNotificationHandlers.push(handler);
}
@@ -31,6 +36,9 @@ function emitExtensionNotification(notification: QuerymtExtensionNotification):
}
}
+const wildcardNotificationHandlers = vi.hoisted(
+ () => [] as Array<(message: { method: string; params: unknown }) => unknown>
+);
const createWebSocketAcpStream = vi.hoisted(() => vi.fn());
const createTauriAcpStream = vi.hoisted(() => vi.fn());
const ClientSideConnectionMock = vi.hoisted(() =>
@@ -54,14 +62,59 @@ vi.mock('./transport', () => ({
createTauriAcpStream
}));
-vi.mock('@agentclientprotocol/sdk', async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- ClientSideConnection: ClientSideConnectionMock
+vi.mock('@agentclientprotocol/sdk/unstable', async (importOriginal) => {
+ const actual = await importOriginal();
+ // The SDK's app API replaced the connection class. Each `connect()` builds one
+ // mock instance so existing tests can still script `initialize`/`extMethod`.
+ const client = () => {
+ const builder = {
+ onNotification: (name: string, handler: (message: { method: string; params: unknown }) => unknown) => {
+ if (name === '*') wildcardNotificationHandlers.push(handler);
+ return builder;
+ },
+ onRequest: () => builder,
+ connect: () => {
+ const instance = new (ClientSideConnectionMock as unknown as new () => {
+ initialize: (params: unknown) => Promise;
+ extMethod: (method: string, params: unknown) => Promise;
+ })();
+ return {
+ agent: {
+ request: (method: string, params: unknown) =>
+ method === 'initialize' ? instance.initialize(params) : instance.extMethod(method, params),
+ notify: async () => undefined
+ }
+ };
+ }
+ };
+ return builder;
};
+ return { ...actual, client };
});
+const REQUEST_METHODS: Record = {
+ loadSession: 'session/load',
+ prompt: 'session/prompt',
+ newSession: 'session/new',
+ listSessions: 'session/list',
+ unstable_forkSession: 'session/fork'
+};
+
+/** Adapts per-method mocks to the SDK's `connection.agent.request(method, params)` shape. */
+function fakeConnection(handlers: Record unknown>) {
+ const byMethod = new Map(Object.entries(handlers).map(([name, fn]) => [REQUEST_METHODS[name], fn]));
+ return {
+ agent: {
+ request: async (method: string, params: unknown) => {
+ const handler = byMethod.get(method);
+ if (!handler) throw new Error(`unexpected request ${method}`);
+ return handler(params);
+ },
+ notify: async () => undefined
+ }
+ };
+}
+
describe('DesktopAcpClient session metadata', () => {
it('maps an upgrade-required failure on session load and keeps other errors raw', async () => {
const client = new DesktopAcpClient({ id: 'agent-1', name: 'Agent', transport: 'stdio', commandLine: 'agent', enabled: true, autoStart: true });
@@ -71,7 +124,7 @@ describe('DesktopAcpClient session metadata', () => {
});
const missing = new RequestError(-32602, 'Invalid params', { message: 'session not found' });
const loadSession = vi.fn().mockRejectedValueOnce(upgrade).mockRejectedValueOnce(missing);
- (client as unknown as { connection: { loadSession: typeof loadSession } }).connection = { loadSession };
+ (client as unknown as { connection: unknown }).connection = fakeConnection({ loadSession });
await expect(client.loadSession('session-1', '/tmp/work')).rejects.toMatchObject({
name: 'RemoteNodeUpgradeRequiredError',
@@ -85,7 +138,7 @@ describe('DesktopAcpClient session metadata', () => {
it('includes the desktop session-load operation id in ACP metadata', async () => {
const client = new DesktopAcpClient({ id: 'agent-1', name: 'Agent', transport: 'stdio', commandLine: 'agent', enabled: true, autoStart: true });
const loadSession = vi.fn(async () => ({ configOptions: [] }));
- (client as unknown as { connection: { loadSession: typeof loadSession } }).connection = { loadSession };
+ (client as unknown as { connection: unknown }).connection = fakeConnection({ loadSession });
const loaded = await client.loadSession('session-1', '/tmp/work', 'operation-1');
expect(loaded.response).toEqual({ configOptions: [] });
@@ -113,7 +166,7 @@ describe('DesktopAcpClient windowed history', () => {
const sessionHistory = vi.fn(async () => ({ events: [], history: { complete: true }, truncated: [] }));
const sessionToolContent = vi.fn(async () => ({ content: 'full' }));
const internal = client as unknown as Record;
- internal.connection = { loadSession };
+ internal.connection = fakeConnection({ loadSession });
internal.controlCapabilities = { methods, features };
internal.querymtExtensions = { attachRemoteSession, sessionHistory, sessionToolContent };
return { client, loadSession, attachRemoteSession, sessionHistory, sessionToolContent };
@@ -175,7 +228,7 @@ describe('DesktopAcpClient windowed history', () => {
function promptClient(capabilities: { image?: boolean; embeddedContext?: boolean }) {
const client = new DesktopAcpClient({ id: 'agent-1', name: 'Agent', transport: 'stdio', commandLine: 'agent', enabled: true, autoStart: true });
const prompt = vi.fn(async (_request: unknown) => ({ stopReason: 'end_turn' as const }));
- (client as unknown as { connection: { prompt: typeof prompt }; initializeResponse: unknown }).connection = { prompt };
+ (client as unknown as { connection: unknown; initializeResponse: unknown }).connection = fakeConnection({ prompt });
(client as unknown as { initializeResponse: unknown }).initializeResponse = {
protocolVersion: 1,
agentCapabilities: { promptCapabilities: capabilities },
@@ -214,7 +267,7 @@ describe('DesktopAcpClient worktrees', () => {
it('sends session workspace metadata only when requested', async () => {
const { client } = promptClient({});
const newSession = vi.fn(async () => ({ sessionId: 'n' }));
- (client as unknown as { connection: unknown }).connection = { newSession };
+ (client as unknown as { connection: unknown }).connection = fakeConnection({ newSession });
await client.createSession('/p');
expect(newSession).toHaveBeenLastCalledWith({ cwd: '/p', mcpServers: [], _meta: undefined });
await client.createSession('/p', 'prof', { execution: 'worktree' });
@@ -254,7 +307,7 @@ describe('DesktopAcpClient worktrees', () => {
it('lists children with the children scope', async () => {
const { client } = promptClient({});
const listSessions = vi.fn(async () => ({ sessions: [] }));
- (client as unknown as { connection: unknown }).connection = { listSessions };
+ (client as unknown as { connection: unknown }).connection = fakeConnection({ listSessions });
await client.listSessionChildren('p1');
expect(listSessions).toHaveBeenCalledWith({ _meta: { session_scope: 'children', querymt: { parentSessionId: 'p1' } } });
});
@@ -409,7 +462,7 @@ describe('DesktopAcpClient forkSession', () => {
it('uses native ACP fork with QueryMT historical message metadata', async () => {
const client = new DesktopAcpClient({ id: 'agent-1', name: 'Agent', transport: 'stdio', commandLine: 'agent', enabled: true, autoStart: true });
const unstable_forkSession = vi.fn(async () => ({ sessionId: 'fork-1' }));
- (client as unknown as { connection: { unstable_forkSession: typeof unstable_forkSession } }).connection = { unstable_forkSession };
+ (client as unknown as { connection: unknown }).connection = fakeConnection({ unstable_forkSession });
await expect(client.forkSession('source-1', '/tmp/work', 'assistant-2')).resolves.toEqual({ sessionId: 'fork-1' });
expect(unstable_forkSession).toHaveBeenCalledWith({
@@ -467,6 +520,17 @@ describe('DesktopAcpClient connect cancellation', () => {
});
});
+ it('advertises compaction support so the agent sends compaction updates', async () => {
+ createWebSocketAcpStream.mockResolvedValueOnce(mockAcpStream());
+
+ await websocketClient().connect();
+
+ const initialize = ClientSideConnectionMock.mock.instances[0]?.initialize as ReturnType;
+ expect(initialize.mock.calls[0]?.[0]).toMatchObject({
+ clientCapabilities: { session: { compaction: {} } }
+ });
+ });
+
it('constructs the internal ACP WebSocket URL from the configured server address', async () => {
const stream = mockAcpStream();
createWebSocketAcpStream.mockResolvedValueOnce(stream);
@@ -862,6 +926,28 @@ describe('DesktopAcpClient elicitation recovery', () => {
expect(client.supportsElicitationRecovery()).toBe(false);
});
+ it('normalizes prefixed wire notifications from the wildcard handler before forwarding', async () => {
+ const stream = mockAcpStream();
+ createWebSocketAcpStream.mockResolvedValueOnce(stream);
+ wildcardNotificationHandlers.length = 0;
+ const client = websocketClient();
+ await client.connect();
+
+ const wildcard = wildcardNotificationHandlers.at(-1);
+ expect(wildcard).toBeDefined();
+ await wildcard!({
+ method: '_querymt/elicitation/recoveryAuthority',
+ params: { version: 1, session_id: 'session-1', resume_authority: 'process-secret-1' }
+ });
+
+ expect(client.hasElicitationAuthority('session-1')).toBe(true);
+
+ const seen: string[] = [];
+ extensionNotificationHandlers.push((notification) => seen.push(notification.method));
+ await wildcard!({ method: 'vendor/custom', params: {} });
+ expect(seen).toEqual(['vendor/custom']);
+ });
+
it('stores resume authority in memory across WebSocket client replacement without logging it', async () => {
const stream = mockAcpStream();
createWebSocketAcpStream.mockResolvedValueOnce(stream);
diff --git a/src/lib/querymt/acp-client.ts b/src/lib/querymt/acp-client.ts
index e50046f..56ec223 100644
--- a/src/lib/querymt/acp-client.ts
+++ b/src/lib/querymt/acp-client.ts
@@ -1,6 +1,7 @@
import {
- ClientSideConnection,
+ client as createAcpClient,
type ClientCapabilities,
+ type ClientConnection,
type ContentBlock,
type CreateElicitationRequest,
type CreateElicitationResponse,
@@ -18,7 +19,7 @@ import {
type SessionConfigOption,
type SessionNotification,
type SetSessionConfigOptionRequest
-} from '@agentclientprotocol/sdk';
+} from '@agentclientprotocol/sdk/unstable';
import type { AgentConfig, AgentControlHealth, ModelEntry, ModelInfo, PromptAttachment, PromptSendOptions, SessionPin, SessionLifecycle, SessionLifecyclePatch } from '$lib/domain/types';
import type {
AttachRemoteSessionRequest,
@@ -124,7 +125,7 @@ import {
} from '$lib/domain/session-workspace';
import { preserveElicitationOptionDescriptionsOnStream } from '$lib/querymt/elicitation-option-meta';
import { createTauriAcpStream, createWebSocketAcpStream } from '$lib/querymt/transport';
-import type { Stream } from '@agentclientprotocol/sdk';
+import type { Stream } from '@agentclientprotocol/sdk/unstable';
import { isEmbedded } from '$lib/platform/runtime';
import { acpWebSocketUrl } from '$lib/querymt/websocket-url';
import { mapRemoteUpgradeRequired } from '$lib/domain/remote-errors';
@@ -337,7 +338,7 @@ export interface LoadedAcpSession {
export class DesktopAcpClient {
private config: AgentConfig;
private browserClient = new BrowserClient();
- private connection: ClientSideConnection | null = null;
+ private connection: ClientConnection | null = null;
private stream: Stream | null = null;
private initializeResponse: InitializeResponse | null = null;
private querymtExtensions: QuerymtExtensions | null = null;
@@ -394,7 +395,16 @@ export class DesktopAcpClient {
}
const browserClient = this.browserClient;
- const connection = new ClientSideConnection(() => browserClient, stream);
+ const connection = createAcpClient({ name: 'querymt-desktop' })
+ .onNotification('session/update', ({ params }) => browserClient.sessionUpdate(params))
+ // Anything that is not a standard notification is a QueryMT extension.
+ // Normalize `_querymt/...` wire names to logical names so internal listeners match.
+ .onNotification('*', ({ method, params }) =>
+ browserClient.extNotification(toLogicalQuerymtMethod(method) ?? method, params)
+ )
+ .onRequest('session/request_permission', ({ params }) => browserClient.requestPermission(params))
+ .onRequest('elicitation/create', ({ params }) => browserClient.unstable_createElicitation(params))
+ .connect(stream);
this.stream = stream;
this.connection = connection;
@@ -409,7 +419,7 @@ export class DesktopAcpClient {
let initializeResponse: InitializeResponse;
try {
- initializeResponse = await connection.initialize(request);
+ initializeResponse = await connection.agent.request('initialize', request);
} catch (error) {
if (!this.connectIsCurrent(epoch, connection)) {
throw connectCancelledError();
@@ -573,7 +583,7 @@ export class DesktopAcpClient {
await this.connect();
}
- return this.connection!.listSessions(request);
+ return this.connection!.agent.request('session/list', request);
}
/** Direct children (forks and worktree children) of one session; the caller filters by parent. */
@@ -586,7 +596,7 @@ export class DesktopAcpClient {
await this.connect();
}
- await this.connection!.deleteSession({ sessionId });
+ await this.connection!.agent.request('session/delete', { sessionId });
}
async createSession(
@@ -601,7 +611,7 @@ export class DesktopAcpClient {
const querymt: Record = {};
if (profileId) querymt.profile_id = profileId;
if (sessionWorkspace) querymt.sessionWorkspace = sessionWorkspace;
- return this.connection!.newSession({
+ return this.connection!.agent.request('session/new', {
cwd,
mcpServers: [],
_meta: Object.keys(querymt).length ? { querymt } : undefined
@@ -647,7 +657,7 @@ export class DesktopAcpClient {
const capture = this.browserClient.beginSessionReplay(sessionId);
try {
- const response = await this.connection!.loadSession({
+ const response = await this.connection!.agent.request('session/load', {
sessionId,
cwd,
mcpServers: [],
@@ -675,7 +685,7 @@ export class DesktopAcpClient {
await this.connect();
}
- return this.connection!.unstable_forkSession({
+ return this.connection!.agent.request('session/fork', {
sessionId,
cwd,
mcpServers: [],
@@ -1072,7 +1082,7 @@ export class DesktopAcpClient {
await this.connect();
}
- const response = await this.connection!.setSessionConfigOption(request);
+ const response = await this.connection!.agent.request('session/set_config_option', request);
return response.configOptions ?? [];
}
@@ -1095,7 +1105,7 @@ export class DesktopAcpClient {
}
const content = this.buildPromptContent(prompt, attachments, options.imageMode ?? 'image');
try {
- return await this.connection!.prompt({
+ return await this.connection!.agent.request('session/prompt', {
sessionId,
prompt: content,
_meta: options.clientPromptId
@@ -1229,7 +1239,7 @@ export class DesktopAcpClient {
await this.connect();
}
- await this.connection!.cancel({ sessionId });
+ await this.connection!.agent.notify('session/cancel', { sessionId });
}
async disconnect() {
@@ -1287,7 +1297,7 @@ export class DesktopAcpClient {
return () => this.browserClient.offExtensionNotification(wrapped);
}
- private connectIsCurrent(epoch: number, connection?: ClientSideConnection): boolean {
+ private connectIsCurrent(epoch: number, connection?: ClientConnection): boolean {
return (
this.connectEpoch === epoch &&
!this.intentionallyDisconnected &&
@@ -1391,6 +1401,9 @@ function buildClientCapabilities(): ClientCapabilities {
elicitation,
fs: {},
terminal: false,
+ // Compaction updates are ID-addressed (`compaction_update` and
+ // `compaction_summary_chunk`); without this the agent never sends them.
+ session: { compaction: {} },
// `ClientCapabilities` is non-exhaustive in the ACP SDK, so the QueryMT
// namespace is advertised through `_meta` (ACP's extensibility escape
// hatch). Declaring epoch 1 tells the agent this client hydrates history
diff --git a/src/lib/querymt/browser-client.test.ts b/src/lib/querymt/browser-client.test.ts
index 79c1ea1..6fb004c 100644
--- a/src/lib/querymt/browser-client.test.ts
+++ b/src/lib/querymt/browser-client.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
-import type { SessionNotification } from '@agentclientprotocol/sdk';
+import type { SessionNotification } from '@agentclientprotocol/sdk/unstable';
import { BrowserClient } from './browser-client';
function notification(sessionId: string, text: string): SessionNotification {
diff --git a/src/lib/querymt/browser-client.ts b/src/lib/querymt/browser-client.ts
index fc921e0..ed0cc4b 100644
--- a/src/lib/querymt/browser-client.ts
+++ b/src/lib/querymt/browser-client.ts
@@ -1,11 +1,10 @@
import type {
- Client,
CreateElicitationRequest,
CreateElicitationResponse,
RequestPermissionRequest,
RequestPermissionResponse,
SessionNotification
-} from '@agentclientprotocol/sdk';
+} from '@agentclientprotocol/sdk/unstable';
import type { QuerymtExtensionNotification } from '$lib/querymt/querymt-extensions';
export interface SessionReplayCapture {
@@ -13,7 +12,7 @@ export interface SessionReplayCapture {
notifications: SessionNotification[];
}
-export class BrowserClient implements Client {
+export class BrowserClient {
private sessionUpdateHandlers: Array<(notification: SessionNotification) => void> = [];
private replayCaptures = new Map();
private permissionRequestHandlers: Array<
diff --git a/src/lib/querymt/config-options.ts b/src/lib/querymt/config-options.ts
index 6bbe488..64c4bf3 100644
--- a/src/lib/querymt/config-options.ts
+++ b/src/lib/querymt/config-options.ts
@@ -3,7 +3,7 @@ import type {
SessionConfigSelectGroup,
SessionConfigSelectOption,
SetSessionConfigOptionRequest
-} from '@agentclientprotocol/sdk';
+} from '@agentclientprotocol/sdk/unstable';
import type { ComposerOption, ModelEntry } from '$lib/domain/types';
export const CONFIG_MODEL = 'model' as const;
diff --git a/src/lib/querymt/elicitation-option-meta.test.ts b/src/lib/querymt/elicitation-option-meta.test.ts
index bdb5b7d..b131dd1 100644
--- a/src/lib/querymt/elicitation-option-meta.test.ts
+++ b/src/lib/querymt/elicitation-option-meta.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
-import type { AnyMessage } from '@agentclientprotocol/sdk/dist/jsonrpc.js';
+import type { AnyMessage } from '@agentclientprotocol/sdk/unstable';
import {
hoistElicitationOptionDescriptions,
optionDescriptionsFromMeta,
diff --git a/src/lib/querymt/elicitation-option-meta.ts b/src/lib/querymt/elicitation-option-meta.ts
index 05af1d1..9c53428 100644
--- a/src/lib/querymt/elicitation-option-meta.ts
+++ b/src/lib/querymt/elicitation-option-meta.ts
@@ -1,5 +1,5 @@
-import type { Stream } from '@agentclientprotocol/sdk';
-import type { AnyMessage } from '@agentclientprotocol/sdk/dist/jsonrpc.js';
+import type { Stream } from '@agentclientprotocol/sdk/unstable';
+import type { AnyMessage } from '@agentclientprotocol/sdk/unstable';
export const QUERYMT_OPTION_DESCRIPTIONS_KEY = 'optionDescriptions';
diff --git a/src/lib/querymt/querymt-extensions.test.ts b/src/lib/querymt/querymt-extensions.test.ts
index 286221a..ee0e3b3 100644
--- a/src/lib/querymt/querymt-extensions.test.ts
+++ b/src/lib/querymt/querymt-extensions.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
-import type { ClientSideConnection } from '@agentclientprotocol/sdk';
+import type { ClientConnection } from '@agentclientprotocol/sdk/unstable';
import {
QMT_ELICITATION_RECOVERY_VERSION,
QMT_METHOD_ELICITATION_ATTACH_SESSION,
@@ -51,10 +51,10 @@ function capabilitiesFixture(
}
function extensionClient() {
- const extMethod = vi.fn(async (): Promise => ({}));
+ const extMethod = vi.fn(async (..._args: unknown[]): Promise => ({}));
const extensions = new QuerymtExtensions({
- extMethod
- } as unknown as ClientSideConnection);
+ agent: { request: extMethod }
+ } as unknown as ClientConnection);
return { extensions, extMethod };
}
diff --git a/src/lib/querymt/querymt-extensions.ts b/src/lib/querymt/querymt-extensions.ts
index 84a1c76..60ee10b 100644
--- a/src/lib/querymt/querymt-extensions.ts
+++ b/src/lib/querymt/querymt-extensions.ts
@@ -48,7 +48,7 @@ import type {
SessionToolContentResponse,
UndoStackFrameResponse
} from '$lib/querymt/generated/types';
-import type { ClientSideConnection } from '@agentclientprotocol/sdk';
+import type { ClientConnection } from '@agentclientprotocol/sdk/unstable';
/** OAuth flow kinds reported by `querymt/auth/start` (`flow_kind` on the wire). */
export enum OAuthFlowKind {
@@ -360,7 +360,7 @@ export interface QuerymtRedoResponse extends QuerymtUndoStackResponse {
export interface QuerymtSubmitInputRequest {
session_id: string;
- prompt: import('@agentclientprotocol/sdk').ContentBlock[];
+ prompt: import('@agentclientprotocol/sdk/unstable').ContentBlock[];
client_input_id: string;
expected_run_id?: string;
}
@@ -460,10 +460,10 @@ export function toLogicalQuerymtMethod(method: string): QuerymtLogicalMethod | n
}
export class QuerymtExtensions {
- constructor(private connection: ClientSideConnection) {}
+ constructor(private connection: ClientConnection) {}
private async call(method: QuerymtLogicalMethod, params: unknown = {}): Promise {
- const response = await this.connection.extMethod(
+ const response = await this.connection.agent.request>(
toAcpExtensionMethod(method),
params as Record
);
diff --git a/src/lib/querymt/sdk-wiring.test.ts b/src/lib/querymt/sdk-wiring.test.ts
new file mode 100644
index 0000000..ea5ab89
--- /dev/null
+++ b/src/lib/querymt/sdk-wiring.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it, vi } from 'vitest';
+import { agent, client, type AgentConnection } from '@agentclientprotocol/sdk/unstable';
+
+// Exercises the handler wiring DesktopAcpClient uses against the real SDK, so
+// schema validation (including compaction updates) is not mocked away.
+describe('ACP SDK wiring', () => {
+ it('delivers compaction updates and extension notifications, and serves extension requests', async () => {
+ const sessionUpdates: Array> = [];
+ const extNotifications: Array<{ method: string; params: unknown }> = [];
+ const capabilities = vi.fn();
+ let agentSide!: AgentConnection;
+
+ const agentApp = agent()
+ .onConnect((connection) => { agentSide = connection; })
+ .onRequest('initialize', ({ params }) => {
+ capabilities(params.clientCapabilities);
+ return { protocolVersion: 1, agentCapabilities: {}, authMethods: [] };
+ })
+ .onRequest('_querymt/capabilities', (value: unknown) => value, () => ({ querymt_control_version: 1 }));
+
+ const clientApp = client()
+ .onNotification('session/update', ({ params }) => { sessionUpdates.push(params.update as Record); })
+ .onNotification('*', ({ method, params }) => { extNotifications.push({ method, params }); });
+
+ const connection = clientApp.connect(agentApp);
+ await connection.agent.request('initialize', {
+ protocolVersion: 1,
+ clientCapabilities: { session: { compaction: {} } }
+ });
+ expect(capabilities).toHaveBeenCalledWith(expect.objectContaining({ session: { compaction: {} } }));
+
+ await agentSide.client.notify('session/update', {
+ sessionId: 's1',
+ update: { sessionUpdate: 'compaction_update', compactionId: 'c1', status: 'in_progress' }
+ });
+ await agentSide.client.notify('session/update', {
+ sessionId: 's1',
+ update: { sessionUpdate: 'compaction_summary_chunk', compactionId: 'c1', content: { type: 'text', text: 'hi' } }
+ });
+ await agentSide.client.notify('_querymt/session/runtimeChanged', { session_id: 's1' });
+
+ await vi.waitFor(() => expect(sessionUpdates).toHaveLength(2));
+ expect(sessionUpdates[0]).toMatchObject({ sessionUpdate: 'compaction_update', compactionId: 'c1', status: 'in_progress' });
+ expect(sessionUpdates[1]).toMatchObject({ sessionUpdate: 'compaction_summary_chunk', content: { text: 'hi' } });
+ await vi.waitFor(() => expect(extNotifications).toEqual([
+ { method: '_querymt/session/runtimeChanged', params: { session_id: 's1' } }
+ ]));
+
+ const response = await connection.agent.request<{ querymt_control_version: number }, Record>(
+ '_querymt/capabilities', {}
+ );
+ expect(response.querymt_control_version).toBe(1);
+ connection.close();
+ });
+});
diff --git a/src/lib/querymt/transport-tauri.ts b/src/lib/querymt/transport-tauri.ts
index 3f4104c..d15a83a 100644
--- a/src/lib/querymt/transport-tauri.ts
+++ b/src/lib/querymt/transport-tauri.ts
@@ -1,4 +1,4 @@
-import { ndJsonStream, type Stream } from '@agentclientprotocol/sdk';
+import { ndJsonStream, type Stream } from '@agentclientprotocol/sdk/unstable';
export async function createTauriAcpStream(agentId: string): Promise {
const { Channel, invoke } = await import('@tauri-apps/api/core');
diff --git a/src/lib/querymt/transport.ts b/src/lib/querymt/transport.ts
index cc85d0d..014aefd 100644
--- a/src/lib/querymt/transport.ts
+++ b/src/lib/querymt/transport.ts
@@ -1,5 +1,5 @@
-import type { Stream } from '@agentclientprotocol/sdk';
-import type { AnyMessage } from '@agentclientprotocol/sdk/dist/jsonrpc.js';
+import type { Stream } from '@agentclientprotocol/sdk/unstable';
+import type { AnyMessage } from '@agentclientprotocol/sdk/unstable';
export async function createTauriAcpStream(agentId: string): Promise {
const { createTauriAcpStream: createStream } = await import('$native');
diff --git a/src/lib/stores/agents.svelte.test.ts b/src/lib/stores/agents.svelte.test.ts
index c75e4b3..db54a53 100644
--- a/src/lib/stores/agents.svelte.test.ts
+++ b/src/lib/stores/agents.svelte.test.ts
@@ -7,7 +7,7 @@ import SessionToolBlock from '$lib/components/session/SessionToolBlock.svelte';
import SessionDelegatesBubble from '$lib/components/session/SessionDelegatesBubble.svelte';
import { getSessionToolPresentation } from '$lib/domain/session-tool-presentation';
import SessionHeader from '$lib/components/session/SessionHeader.svelte';
-import { RequestError, type InitializeResponse, type ListSessionsRequest, type PromptResponse, type SessionConfigOption, type SessionInfo, type SessionNotification, type SetSessionConfigOptionRequest } from '@agentclientprotocol/sdk';
+import { RequestError, type InitializeResponse, type ListSessionsRequest, type PromptResponse, type SessionConfigOption, type SessionInfo, type SessionNotification, type SetSessionConfigOptionRequest } from '@agentclientprotocol/sdk/unstable';
import type { AgentConfig, DesktopSessionSummary, ModelEntry, PromptAttachment, PromptSendOptions } from '$lib/domain/types';
import { findModeConfigOption, findReasoningConfigOption, getModelSelectionKey } from '$lib/querymt/config-options';
import { QMT_METHOD_MESH_NODES, QMT_METHOD_MESH_STATUS } from '$lib/querymt/querymt-extensions';
@@ -7884,6 +7884,25 @@ describe('AgentsStore prompt session start', () => {
expect(mockClient.steerSession).not.toHaveBeenCalled();
});
+ it('labels a /compact prompt as compacting instead of a generic wait', async () => {
+ let resolvePrompt!: () => void;
+ mockClient.sendPrompt.mockImplementationOnce(
+ () => new Promise((resolve) => { resolvePrompt = () => resolve({ stopReason: 'end_turn' }); })
+ );
+ const store = createStore();
+ store.activeAgentId = 'agent-1';
+ store.activeSessionId = 'session-1';
+ store.composerPrompt = '/compact keep the API notes';
+
+ const sending = store.sendPromptToActiveSession();
+ await vi.waitFor(() => expect(mockClient.sendPrompt).toHaveBeenCalled());
+
+ expect(store.activeSession.runState).toBe('thinking');
+ expect(store.activeSession.activityLabel).toBe('Compacting context\u2026');
+ resolvePrompt();
+ await sending;
+ });
+
it('sends attachment-only prompts and keeps structured optimistic blocks', async () => {
const store = createStore();
store.activeAgentId = 'agent-1';
diff --git a/src/lib/stores/agents.svelte.ts b/src/lib/stores/agents.svelte.ts
index c8dba65..bc11d91 100644
--- a/src/lib/stores/agents.svelte.ts
+++ b/src/lib/stores/agents.svelte.ts
@@ -1,4 +1,4 @@
-import { RequestError } from '@agentclientprotocol/sdk';
+import { RequestError } from '@agentclientprotocol/sdk/unstable';
import type {
InitializeResponse,
ListSessionsResponse,
@@ -9,7 +9,7 @@ import type {
SessionConfigOption,
SessionInfo,
SessionNotification
-} from '@agentclientprotocol/sdk';
+} from '@agentclientprotocol/sdk/unstable';
import { tick } from 'svelte';
import {
buildSessionWorkspaceRequest,
@@ -64,6 +64,7 @@ import {
normalizeContentBlock,
readClientPromptId
} from '$lib/domain/session-updates';
+import { COMPACTING_LABEL, isCompactCommand } from '$lib/domain/session-compaction';
import {
buildListSessionsRequest,
createSessionBrowserState,
@@ -3836,7 +3837,7 @@ export class AgentsStore {
this.activeSession.undo.lastRevertedFiles = [];
this.activeSession.undo.lastMessage = null;
this.activeSession.runState = 'thinking';
- this.activeSession.activityLabel = 'Waiting for the agent to respond…';
+ this.activeSession.activityLabel = isCompactCommand(prompt) ? COMPACTING_LABEL : 'Waiting for the agent to respond…';
beginSessionWork(this.activeSession);
this.activeSession.lastError = null;
if (!retryFailure && !explicit) {
diff --git a/src/lib/stores/embedded-agents.test.ts b/src/lib/stores/embedded-agents.test.ts
index 6643a78..26b9903 100644
--- a/src/lib/stores/embedded-agents.test.ts
+++ b/src/lib/stores/embedded-agents.test.ts
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import type { ListSessionsRequest, ListSessionsResponse } from '@agentclientprotocol/sdk';
+import type { ListSessionsRequest, ListSessionsResponse } from '@agentclientprotocol/sdk/unstable';
import type { AgentsStore } from './agents.svelte';
import { SESSION_CATALOG_STORAGE_KEY } from './session-catalog-cache';
import { EMBEDDED_AGENT_ID } from '$lib/platform/embedded-agent';
diff --git a/src/lib/stores/inbox.svelte.test.ts b/src/lib/stores/inbox.svelte.test.ts
index 711fc43..22c50f8 100644
--- a/src/lib/stores/inbox.svelte.test.ts
+++ b/src/lib/stores/inbox.svelte.test.ts
@@ -4,7 +4,7 @@ import type {
CreateElicitationResponse,
RequestPermissionRequest,
RequestPermissionResponse
-} from '@agentclientprotocol/sdk';
+} from '@agentclientprotocol/sdk/unstable';
import { InboxStore } from './inbox.svelte';
const sendDesktopNotification = vi.hoisted(() => vi.fn(async () => undefined));
diff --git a/src/lib/stores/inbox.svelte.ts b/src/lib/stores/inbox.svelte.ts
index 5c0a8b8..cee320a 100644
--- a/src/lib/stores/inbox.svelte.ts
+++ b/src/lib/stores/inbox.svelte.ts
@@ -3,7 +3,7 @@ import type {
CreateElicitationResponse,
RequestPermissionRequest,
RequestPermissionResponse
-} from '@agentclientprotocol/sdk';
+} from '@agentclientprotocol/sdk/unstable';
import {
buildElicitationResponse,
describeElicitationResolution,
diff --git a/src/lib/stores/session-list-coordination.ts b/src/lib/stores/session-list-coordination.ts
index da4f3d7..4787c9c 100644
--- a/src/lib/stores/session-list-coordination.ts
+++ b/src/lib/stores/session-list-coordination.ts
@@ -1,4 +1,4 @@
-import type { ListSessionsResponse } from '@agentclientprotocol/sdk';
+import type { ListSessionsResponse } from '@agentclientprotocol/sdk/unstable';
export interface SessionListChannel {
onmessage: ((event: { data: unknown }) => void) | null;