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 @@ + +
+ +
+ + + {presentation.title} + + {#if presentation.detail} + {presentation.detail} + {/if} + {#if hasSummary} + + {/if} +
+ + {#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;