mirror of
https://github.com/microsoft/vscode.git
synced 2026-09-06 22:54:50 +01:00
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:
@@ -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.
|
||||
|
||||
+21
@@ -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
|
||||
+21
@@ -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
|
||||
+21
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user