Improve Agent Host E2E test coverage (#330550)

Improve Agent Host E2E coverage

(Written by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
roblourens
2026-08-13 01:26:39 +00:00
committed by GitHub
co-authored by Copilot
parent 670963f903
commit f587ef679c
11 changed files with 1968 additions and 1018 deletions
@@ -16,6 +16,23 @@ When a valid E2E scenario exposes a gap:
Capability skips are tracked separately from suspected bugs. A provider that does not advertise a capability is expected to skip positive-path tests for that capability.
### Duplicate session creation is accepted
A client can retry session creation with a URI that already identifies a live session. The host accepts the duplicate request instead of reporting that the resource already exists, so clients cannot distinguish an idempotent retry from an accidental collision and a provider may be asked to create conflicting backing state.
- Test: `creating a duplicate session resource is rejected`.
- Scope: conformance reference provider on all platforms.
- Expected: the second AHP `createSession` request fails with `SessionAlreadyExists`.
- Observed: the second request resolves successfully.
- Gate: the scenario requires `AGENT_HOST_RUN_KNOWN_ISSUES=1`.
- Reproduce:
```bash
AGENT_HOST_RUN_KNOWN_ISSUES=1 ./scripts/test-integration.sh --run \
src/vs/platform/agentHost/test/node/e2e/conformance/agentHostConformance.integrationTest.ts \
--grep "creating a duplicate session resource is rejected"
```
### Deleting a worktree session can race background Git work
A user can configure ignored files to be copied into an isolated worktree, complete an agent turn, and then delete the session. Session deletion can fail because background changeset or Git-state work is still using the worktree while Git removes it, leaving the session's worktree behind.
@@ -0,0 +1,21 @@
version: 1
dialect: anthropic
exchanges:
- request:
model: claude-opus-5
system: ${system}
messages:
- role: user
content: |-
Reply with exactly this Markdown code block and nothing else:
```text
ALPHA
BETA
```
response:
content: |-
```text
ALPHA
BETA
```
stopReason: end_turn
@@ -0,0 +1,21 @@
version: 1
dialect: responses
exchanges:
- request:
model: gpt-5.3-codex
system: ${system}
messages:
- role: user
content: |-
Reply with exactly this Markdown code block and nothing else:
```text
ALPHA
BETA
```
response:
content: |-
```text
ALPHA
BETA
```
stopReason: end_turn
@@ -0,0 +1,21 @@
version: 1
dialect: anthropic
exchanges:
- request:
model: claude-sonnet-5
system: ${system}
messages:
- role: user
content: |-
Reply with exactly this Markdown code block and nothing else:
```text
ALPHA
BETA
```
response:
content: |-
```text
ALPHA
BETA
```
stopReason: end_turn
File diff suppressed because it is too large Load Diff
@@ -30,6 +30,7 @@ const isLinux = process.platform === 'linux';
const RECORD = process.env['AGENT_HOST_REPLAY_RECORD'] === '1' || process.env['AGENT_HOST_UPDATE_SNAPSHOTS'] === '1';
const RUN_RECORD_ONLY_TESTS = process.env['AGENT_HOST_REPLAY_RECORD'] === '1';
const RUN_KNOWN_ISSUE_TESTS = RECORD && process.env['AGENT_HOST_RUN_KNOWN_ISSUES'] === '1';
const RUN_HOST_ONLY_KNOWN_ISSUE_TESTS = process.env['AGENT_HOST_RUN_KNOWN_ISSUES'] === '1';
const isWindows = process.platform === 'win32';
interface IDefineOptions {
@@ -57,6 +58,7 @@ function defineSuite(config: IAgentHostE2EProviderConfig, options: IDefineOption
isWindows,
runRecordOnlyTests: RUN_RECORD_ONLY_TESTS,
runKnownIssueTests: RUN_KNOWN_ISSUE_TESTS,
runHostOnlyKnownIssueTests: RUN_HOST_ONLY_KNOWN_ISSUE_TESTS,
registerNoModelTrafficTest: title => noModelTrafficTestTitles.add(title),
get observedModelRequestBodies() { return lease?.observedModelRequestBodies ?? []; },
restartServer: async () => {
@@ -141,6 +141,19 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void {
return `!node -e "require('fs').writeFileSync(process.argv[1],process.argv[2])" ${file} ${contents}`;
}
function writeFileBase64Command(file: string, contents: string): string {
const encodedFile = Buffer.from(file).toString('base64');
const encodedContents = Buffer.from(contents).toString('base64');
return `!node -e "const fs=require('fs');fs.writeFileSync(Buffer.from(process.argv[1],'base64').toString(),Buffer.from(process.argv[2],'base64'))" ${encodedFile} ${encodedContents}`;
}
function writeFileTwiceBase64Command(file: string, first: string, second: string): string {
const encodedFile = Buffer.from(file).toString('base64');
const encodedFirst = Buffer.from(first).toString('base64');
const encodedSecond = Buffer.from(second).toString('base64');
return `!node -e "const fs=require('fs');const file=Buffer.from(process.argv[1],'base64').toString();fs.writeFileSync(file,Buffer.from(process.argv[2],'base64'));fs.writeFileSync(file,Buffer.from(process.argv[3],'base64'))" ${encodedFile} ${encodedFirst} ${encodedSecond}`;
}
function deleteFileCommand(file: string): string {
return `!node -e "require('fs').unlinkSync(process.argv[1])" ${file}`;
}
@@ -153,6 +166,10 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void {
return file.edit.after?.uri ?? file.edit.before?.uri ?? '';
}
function fileHasBasename(file: IObservedChangesetFile, basename: string): boolean {
return URI.parse(fileUri(file)).path.endsWith(`/${basename}`);
}
/**
* Waits for a `changeset/contentChanged` on `channel` that reports
* `basename`. Matched by basename because git resolves symlinks when
@@ -165,10 +182,10 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void {
return false;
}
const action = getActionEnvelope(n).action as IContentChangedAction;
return action.files.some(file => fileUri(file).endsWith(`/${basename}`));
return action.files.some(file => fileHasBasename(file, basename));
}, timeout);
const action = getActionEnvelope(notification).action as IContentChangedAction;
return action.files.find(file => fileUri(file).endsWith(`/${basename}`))!;
return action.files.find(file => fileHasBasename(file, basename))!;
}
async function waitForTurnComplete(sessionUri: string, turnId: string): Promise<void> {
@@ -201,7 +218,7 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void {
const state = await changesetState(channel);
const files: IObservedChangesetFile[] = [];
for (const basename of basenames) {
const file = state.files.find(file => fileUri(file).endsWith(`/${basename}`));
const file = state.files.find(file => fileHasBasename(file, basename));
if (file) {
files.push(file);
}
@@ -489,6 +506,113 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void {
]);
});
conformanceTest(context, 'ignored files do not appear in a branch changeset', async function () {
const workspace = createGitWorkspace('ahp-changeset-ignored-');
writeFileSync(join(workspace, '.gitignore'), 'ignored.log\n');
execSync('git add .gitignore', { cwd: workspace });
execSync('git commit -q -m "ignore generated log"', { cwd: workspace });
const sessionUri = await createSessionIn(workspace, 'changeset-ignored');
const branchUri = buildBranchChangesetUri(sessionUri);
await context.client.call<SubscribeResult>('subscribe', { channel: branchUri });
await changesetState(branchUri);
context.client.clearReceived();
const changed = context.client.waitForNotification(n =>
isActionNotification(n, 'changeset/contentChanged') && getActionEnvelope(n).channel === branchUri,
60_000,
);
await runBangTurn(sessionUri, 'turn-changeset-ignored', writeFileCommand('ignored.log', 'ignored'), 1);
await changed;
const state = await changesetState(branchUri);
assert.deepStrictEqual(state.files, []);
});
conformanceTest(context, 'a file created and deleted in one turn leaves no branch change', async function () {
const workspace = createGitWorkspace('ahp-changeset-create-delete-');
const sessionUri = await createSessionIn(workspace, 'changeset-create-delete');
const branchUri = buildBranchChangesetUri(sessionUri);
await context.client.call<SubscribeResult>('subscribe', { channel: branchUri });
await changesetState(branchUri);
context.client.clearReceived();
const changed = context.client.waitForNotification(n =>
isActionNotification(n, 'changeset/contentChanged') && getActionEnvelope(n).channel === branchUri,
60_000,
);
await runBangTurn(sessionUri, 'turn-changeset-create-delete', '!node -e "const fs=require(\'fs\');fs.writeFileSync(\'temporary.txt\',\'temporary\');fs.unlinkSync(\'temporary.txt\')"', 1);
await changed;
const state = await changesetState(branchUri);
assert.deepStrictEqual(state.files, []);
});
conformanceTest(context, 'an edit restored in the same turn leaves no branch change', async function () {
const workspace = createGitWorkspace('ahp-changeset-edit-restore-');
const sessionUri = await createSessionIn(workspace, 'changeset-edit-restore');
const branchUri = buildBranchChangesetUri(sessionUri);
await context.client.call<SubscribeResult>('subscribe', { channel: branchUri });
await changesetState(branchUri);
context.client.clearReceived();
const changed = context.client.waitForNotification(n =>
isActionNotification(n, 'changeset/contentChanged') && getActionEnvelope(n).channel === branchUri,
60_000,
);
await runBangTurn(sessionUri, 'turn-changeset-edit-restore', writeFileTwiceBase64Command('seed.txt', 'changed', 'seed\n'), 1);
await changed;
const state = await changesetState(branchUri);
assert.deepStrictEqual(state.files, []);
});
conformanceTest(context, 'an added multiline file reports every added line', async function () {
const workspace = createGitWorkspace('ahp-changeset-multiline-add-');
const sessionUri = await createSessionIn(workspace, 'changeset-multiline-add');
const branchUri = buildBranchChangesetUri(sessionUri);
await context.client.call<SubscribeResult>('subscribe', { channel: branchUri });
await runBangTurn(sessionUri, 'turn-changeset-multiline-add', writeFileBase64Command('lines.txt', 'one\ntwo\nthree\n'), 1);
const [file] = await waitForChangesetFiles(branchUri, ['lines.txt']);
assert.deepStrictEqual(file.edit.diff, { added: 3, removed: 0 });
});
conformanceTest(context, 'deleting a multiline tracked file reports every removed line', async function () {
const workspace = createGitWorkspace('ahp-changeset-multiline-delete-');
writeFileSync(join(workspace, 'lines.txt'), 'one\ntwo\nthree\n');
execSync('git add lines.txt', { cwd: workspace });
execSync('git commit -q -m "add multiline file"', { cwd: workspace });
const sessionUri = await createSessionIn(workspace, 'changeset-multiline-delete');
const branchUri = buildBranchChangesetUri(sessionUri);
await context.client.call<SubscribeResult>('subscribe', { channel: branchUri });
await runBangTurn(sessionUri, 'turn-changeset-multiline-delete', deleteFileCommand('lines.txt'), 1);
const [file] = await waitForChangesetFiles(branchUri, ['lines.txt']);
assert.deepStrictEqual(file.edit.diff, { added: 0, removed: 3 });
});
conformanceTest(context, 'a changed filename containing spaces remains addressable', async function () {
const workspace = createGitWorkspace('ahp-changeset-spaced-file-');
const sessionUri = await createSessionIn(workspace, 'changeset-spaced-file');
const branchUri = buildBranchChangesetUri(sessionUri);
await context.client.call<SubscribeResult>('subscribe', { channel: branchUri });
await runBangTurn(sessionUri, 'turn-changeset-spaced-file', writeFileBase64Command('spaced file.txt', 'content\n'), 1);
const [file] = await waitForChangesetFiles(branchUri, ['spaced file.txt']);
assert.deepStrictEqual({
id: URI.parse(file.id).path.endsWith('/spaced file.txt'),
after: file.edit.after?.uri.endsWith('/spaced%20file.txt') || file.edit.after?.uri.endsWith('/spaced file.txt'),
exists: existsSync(join(workspace, 'spaced file.txt')),
}, {
id: true,
after: true,
exists: true,
});
});
conformanceTest(context, 'an empty repository reports an untracked file as added', async function () {
const workspace = mkdtempSync(join(tmpdir(), 'ahp-changeset-empty-repo-'));
tempDirs.push(workspace);
@@ -145,6 +145,43 @@ export function defineClientFilesystemTests(context: IAgentHostE2ETestContext):
]);
});
conformanceTest(context, 'resourceList returns an empty collection for an empty directory', async function () {
await initializeClient('resource-list-empty');
const root = createWorkspace('ahp-resource-list-empty-');
const result = await context.client.call<ResourceListResult>('resourceList', {
channel: ROOT_STATE_URI,
uri: URI.file(root).toString(),
});
assert.deepStrictEqual(result.entries, []);
});
conformanceTest(context, 'resourceWrite truncates an existing file by default', async function () {
await initializeClient('resource-write-default-truncate');
const root = createWorkspace('ahp-resource-write-default-truncate-');
const file = fileUri(root, 'replace.txt');
writeFileSync(join(root, 'replace.txt'), 'LONGER_ORIGINAL');
await writeText(file, 'short');
assert.strictEqual(readFileSync(join(root, 'replace.txt'), 'utf8'), 'short');
});
conformanceTest(context, 'resourceDelete removes an empty directory without recursive mode', async function () {
await initializeClient('resource-delete-empty-directory');
const root = createWorkspace('ahp-resource-delete-empty-directory-');
const directory = join(root, 'empty');
mkdirSync(directory);
await context.client.call('resourceDelete', {
channel: ROOT_STATE_URI,
uri: URI.file(directory).toString(),
});
assert.strictEqual(existsSync(directory), false);
});
conformanceTest(context, 'resourceCopy, resourceMove, and resourceDelete mutate the tree', async function () {
await initializeClient('resource-mutate');
const root = createWorkspace('ahp-resource-mutate-');
@@ -141,6 +141,30 @@ export function defineCoreTests(context: IAgentHostE2ETestContext): void {
assert.ok(responseParts.length > 0, 'should have received at least one response part');
});
test('preserves a fenced multiline markdown response', async function () {
this.timeout(120_000);
const workspaceDir = mkdtempSync(join(tmpdir(), 'ahp-markdown-response-'));
tempDirs.push(workspaceDir);
const sessionUri = await createRealSession(
context.client,
config,
`markdown-response-${config.provider}`,
createdSessions,
URI.file(workspaceDir),
);
const expected = '```text\nALPHA\nBETA\n```';
const result = await driveTurnToCompletion(
context.client,
sessionUri,
'turn-markdown-response',
`Reply with exactly this Markdown code block and nothing else:\n${expected}`,
1,
);
assert.strictEqual(result.responseText, expected);
});
test('listModels returns well-shaped model entries after authenticate', async function () {
this.timeout(60_000);
@@ -43,6 +43,8 @@ export interface IAgentHostE2ETestContext {
readonly runRecordOnlyTests: boolean;
/** Whether explicitly requested known-issue reproductions should run against live recording. */
readonly runKnownIssueTests: boolean;
/** Whether explicitly requested model-free known-issue reproductions should run in strict replay. */
readonly runHostOnlyKnownIssueTests: boolean;
readonly registerNoModelTrafficTest: (title: string) => void;
readonly observedModelRequestBodies: readonly string[];
/**
@@ -16,6 +16,7 @@ import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from '../../../../../../base/common/path.js';
import { URI } from '../../../../../../base/common/uri.js';
import { generateUuid } from '../../../../../../base/common/uuid.js';
import { ReconnectResultType, type FetchTurnsResult, type InitializeResult, type ListSessionsResult, type ReconnectResult, type SubscribeResult } from '../../../../common/state/protocol/commands.js';
import type { SessionSummaryChangedParams } from '../../../../common/state/protocol/channels-root/notifications.js';
import type { OtlpExportLogsParams } from '../../../../common/state/protocol/channels-otlp/notifications.js';
@@ -23,7 +24,7 @@ import type { IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnostics
import { ActionType, type StateAction } from '../../../../common/state/sessionActions.js';
import { TerminalClaimKind } from '../../../../common/state/protocol/state.js';
import { buildChatUri, buildDefaultChatUri, MessageKind, ROOT_STATE_URI, SessionStatus, type ChatState, type SessionState, type Turn } from '../../../../common/state/sessionState.js';
import { createRealSession, dispatchTurn } from '../harness/agentHostE2ETestHarness.js';
import { createRealSession, dispatchTurn, resolveGitHubToken } from '../harness/agentHostE2ETestHarness.js';
import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js';
import { AhpErrorCodes, JsonRpcErrorCodes } from '../../../../common/state/sessionProtocol.js';
import { getActionEnvelope, isActionNotification, type TestProtocolClient } from '../../serverIntegrationTestHelpers.js';
@@ -209,6 +210,41 @@ export function defineProtocolContractTests(context: IAgentHostE2ETestContext):
}
});
conformanceTest(context, 'initialize reports the negotiated protocol and sequence', async function () {
const client = await context.connectClient();
try {
const initialized = await client.call<InitializeResult>('initialize', {
channel: ROOT_STATE_URI,
protocolVersions: [PROTOCOL_VERSION],
clientId: `server-identity-${config.provider}`,
clientInfo: { name: 'agent-host-e2e', version: '1.0.0' },
});
assert.deepStrictEqual({
protocolVersion: initialized.protocolVersion,
serverSeqIsNonNegative: initialized.serverSeq >= 0,
}, {
protocolVersion: PROTOCOL_VERSION,
serverSeqIsNonNegative: true,
});
} finally {
client.close();
}
});
conformanceTest(context, 'initialize cannot be repeated after the handshake', async function () {
const client = await initializeAdditionalClient('repeat-initialize');
try {
await assert.rejects(client.call('initialize', {
channel: ROOT_STATE_URI,
protocolVersions: [PROTOCOL_VERSION],
clientId: `repeat-initialize-again-${config.provider}`,
}), { code: JsonRpcErrorCodes.MethodNotFound });
} finally {
client.close();
}
});
conformanceTest(context, 'listSessions includes provider-backed session metadata', async function () {
const { sessionUri, workspace } = await createSession('list-session-metadata');
const chatUri = buildDefaultChatUri(sessionUri);
@@ -668,6 +704,211 @@ export function defineProtocolContractTests(context: IAgentHostE2ETestContext):
}
});
conformanceTest(context, 'resource requests before initialize are rejected', async function () {
const client = await context.connectClient();
try {
await assert.rejects(client.call('resourceResolve', {
channel: ROOT_STATE_URI,
uri: URI.file(tmpdir()).toString(),
}), { code: JsonRpcErrorCodes.MethodNotFound });
} finally {
client.close();
}
});
conformanceTest(context, 'unknown requests after initialize are rejected', async function () {
const client = await initializeAdditionalClient('unknown-request');
try {
await assert.rejects(client.call('agentHostE2E/unknownRequest', {
channel: ROOT_STATE_URI,
}), { code: JsonRpcErrorCodes.MethodNotFound });
} finally {
client.close();
}
});
conformanceTest(context, 'reconnect rejects an unknown client', async function () {
const client = await context.connectClient();
try {
await assert.rejects(client.call('reconnect', {
channel: ROOT_STATE_URI,
clientId: `unknown-reconnect-${config.provider}`,
lastSeenServerSeq: 0,
subscriptions: [],
}), { code: AhpErrorCodes.NotFound });
} finally {
client.close();
}
});
conformanceTest(context, 'creating a session with an unknown provider is rejected', async function () {
const client = await initializeAdditionalClient('unknown-provider');
try {
await assert.rejects(client.call('createSession', {
channel: 'missing-provider:/session',
provider: 'missing-provider',
}), { code: AhpErrorCodes.ProviderNotFound });
} finally {
client.close();
}
});
conformanceTest(context, 'creating a duplicate session resource is rejected', async function () {
const { sessionUri, workspace } = await createSession('duplicate-session');
await assert.rejects(context.client.call('createSession', {
channel: sessionUri,
provider: config.provider,
workingDirectories: [URI.file(workspace).toString()],
config: { isolation: 'folder' },
}), { code: AhpErrorCodes.SessionAlreadyExists });
}, context.runHostOnlyKnownIssueTests);
conformanceTest(context, 'a session cannot fork onto its own resource', async function () {
const { sessionUri } = await createSession('self-fork');
await assert.rejects(context.client.call('createSession', {
channel: sessionUri,
provider: config.provider,
fork: { session: sessionUri, turnId: 'irrelevant' },
}), { code: AhpErrorCodes.SessionAlreadyExists });
});
conformanceTest(context, 'forking from a missing session is rejected', async function () {
const target = URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString();
const missingSource = URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString();
await context.client.call('initialize', {
channel: ROOT_STATE_URI,
protocolVersions: [PROTOCOL_VERSION],
clientId: `missing-fork-source-${config.provider}`,
});
await assert.rejects(context.client.call('createSession', {
channel: target,
provider: config.provider,
fork: { session: missingSource, turnId: 'missing-turn' },
}), { code: AhpErrorCodes.SessionNotFound });
});
conformanceTest(context, 'createSession rejects an active client owned by another connection', async function () {
const client = await context.connectClient();
try {
await client.call('initialize', {
channel: ROOT_STATE_URI,
protocolVersions: [PROTOCOL_VERSION],
clientId: `active-client-owner-${config.provider}`,
});
await assert.rejects(client.call('createSession', {
channel: URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString(),
provider: config.provider,
activeClient: { clientId: 'different-client', displayName: 'Different Client', tools: [] },
}), { code: JsonRpcErrorCodes.InvalidParams });
} finally {
client.close();
}
});
conformanceTest(context, 'createSession seeds a matching active client into session state', async function () {
const workspace = mkdtempSync(join(tmpdir(), 'ahp-active-client-create-'));
tempDirs.push(workspace);
const clientId = `active-client-create-${config.provider}`;
const client = await context.connectClient();
const sessionUri = URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString();
let created = false;
try {
await client.call('initialize', {
channel: ROOT_STATE_URI,
protocolVersions: [PROTOCOL_VERSION],
clientId,
});
await client.call('authenticate', {
channel: ROOT_STATE_URI,
resource: 'https://api.github.com',
token: config.githubToken ?? resolveGitHubToken(),
});
await client.call('createSession', {
channel: sessionUri,
provider: config.provider,
workingDirectories: [URI.file(workspace).toString()],
config: { isolation: 'folder' },
activeClient: { clientId, displayName: 'Creating Client', tools: [] },
});
created = true;
const subscribed = await client.call<SubscribeResult>('subscribe', { channel: sessionUri });
const state = subscribed.snapshot!.state as SessionState;
assert.deepStrictEqual(state.activeClients, [{
clientId,
displayName: 'Creating Client',
tools: [],
}]);
} finally {
if (created) {
await client.call('disposeSession', { channel: sessionUri });
}
client.close();
}
});
conformanceTest(context, 'creating a chat for a missing session is rejected', async function () {
const client = await initializeAdditionalClient('missing-chat-session');
const sessionUri = URI.from({ scheme: config.scheme, path: '/missing-chat-session' }).toString();
try {
await assert.rejects(client.call('createChat', {
channel: sessionUri,
chat: buildChatUri(sessionUri, 'peer'),
}), { code: AhpErrorCodes.SessionNotFound });
} finally {
client.close();
}
});
conformanceTest(context, 'subscribing twice does not duplicate action delivery', async function () {
const { sessionUri } = await createSession('duplicate-subscription');
const chatUri = buildDefaultChatUri(sessionUri);
await context.client.call<SubscribeResult>('subscribe', { channel: chatUri });
await context.client.call<SubscribeResult>('subscribe', { channel: chatUri });
context.client.clearReceived();
const clientSeq = nextClientSeq();
const action = { type: ActionType.ChatDraftChanged, draft: { text: 'single delivery', origin: { kind: MessageKind.User } } } as const;
context.client.dispatch({ channel: chatUri, clientSeq, action });
await context.client.waitForNotification(n =>
isActionNotification(n, action.type)
&& getActionEnvelope(n).channel === chatUri
&& getActionEnvelope(n).origin?.clientSeq === clientSeq,
);
await context.client.call('ping', { channel: ROOT_STATE_URI });
const deliveries = context.client.receivedNotifications(n =>
isActionNotification(n, action.type)
&& getActionEnvelope(n).channel === chatUri
&& getActionEnvelope(n).origin?.clientSeq === clientSeq,
);
assert.strictEqual(deliveries.length, 1);
});
conformanceTest(context, 'resubscribing receives state changed while unsubscribed', async function () {
const { sessionUri } = await createSession('resubscribe-snapshot');
const chatUri = buildDefaultChatUri(sessionUri);
context.client.notify('unsubscribe', { channel: chatUri });
const clientSeq = nextClientSeq();
context.client.dispatch({
channel: chatUri,
clientSeq,
action: {
type: ActionType.ChatDraftChanged,
draft: { text: 'changed while unsubscribed', origin: { kind: MessageKind.User } },
},
});
await context.client.call('ping', { channel: ROOT_STATE_URI });
const subscribed = await context.client.call<SubscribeResult>('subscribe', { channel: chatUri });
const state = subscribed.snapshot!.state as ChatState;
assert.strictEqual(state.draft?.text, 'changed while unsubscribed');
});
// The protocol declares working-directory mutation on both the session and
// chat channels, but the host rejects all four: applying one would change
// the synchronized directory set without reconfiguring the agent's actual