Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
23f879e
fix(vscode): give the edit back when a permission diff is closed
yiliang114 Sep 6, 2026
e8c1a8a
test(vscode): witness the request-id binding deferred from #10534
yiliang114 Sep 6, 2026
3d948f7
test(web-shell): witness the remaining #10534 approval gaps
yiliang114 Sep 6, 2026
be276f1
fix(vscode): gate the diff-dismissal message and its fan-out
yiliang114 Sep 6, 2026
09de71e
fix(web-shell): say why a submission was cancelled, and test the rewind
yiliang114 Sep 6, 2026
bfce659
Merge branch 'main' into fix/vscode-permission-diff-dismissal
yiliang114 Sep 6, 2026
dea09dc
fix(vscode): pin the permission-diff dismissal wire and unlock bare w…
yiliang114 Sep 6, 2026
89cb158
test(vscode): derive the dismissal path assertion from path.normalize
yiliang114 Sep 7, 2026
006ced2
test(web-shell): give the bare-write hand-back witness real diff content
yiliang114 Sep 7, 2026
edaecbc
fix(vscode): drop the unread filePath from the permission-diff close …
yiliang114 Sep 7, 2026
9d0df1c
refactor(web-shell): compose the submission owner guard on its sessio…
yiliang114 Sep 7, 2026
9eab317
Merge origin/main into fix/vscode-permission-diff-dismissal
yiliang114 Sep 7, 2026
211f0b7
style(web-shell): wrap the diff fixture prettier wants broken
yiliang114 Sep 7, 2026
c04b22d
fix(vscode): report a permission diff an id-less close drops
yiliang114 Sep 7, 2026
637a3e9
test(vscode): witness the dismissal teardown and the diff keying
yiliang114 Sep 8, 2026
5d64bf2
test(vscode): pin the permission-diff dismissal hop and add queued to…
yiliang114 Sep 9, 2026
926e916
test(vscode): witness the rewind session capture across a mid-flight …
yiliang114 Sep 9, 2026
993f1d1
Merge origin/main into fix/vscode-permission-diff-dismissal
yiliang114 Sep 9, 2026
8b1ebb2
test(web-shell): assert the answers argument submitPermission forwards
yiliang114 Sep 9, 2026
dc665a6
Merge origin/main into fix/vscode-permission-diff-dismissal
yiliang114 Sep 10, 2026
6e73fd1
Merge origin/main into fix/vscode-permission-diff-dismissal
yiliang114 Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions packages/vscode-ide-companion/src/commands/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,62 @@ describe('registerNewCommands', () => {
);
});

// R3-2: every existing showDiff case leaves permissionRequestId undefined, so
// the hop that binds a diff to a pending approval was never exercised.
it('showDiff forwards a defined permission request id', async () => {
Comment thread
yiliang114 marked this conversation as resolved.
workspaceMock.workspaceFolders = [
{ uri: { fsPath: '/workspace' }, name: 'workspace', index: 0 },
];

registerNewCommands(
context as never,
log,
diffManager as never,
() => [],
vi.fn() as never,
);

await getRegisteredHandler(showDiffCommand)({
path: '/workspace/src/app.ts',
oldText: 'old',
newText: 'new',
readOnly: true,
permissionRequestId: 'req-42',
});

expect(diffManager.showDiff).toHaveBeenCalledWith(
'/workspace/src/app.ts',
'old',
'new',
{ readOnly: true, permissionRequestId: 'req-42' },
);
});

// R3-3: the close side of the same binding. Without the id, closeDiff would
// close any diff for that path, including one owned by another approval.
it('closeDiff forwards a defined permission request id', async () => {
workspaceMock.workspaceFolders = [
{ uri: { fsPath: '/workspace' }, name: 'workspace', index: 0 },
];
const closeDiff = vi.fn().mockResolvedValue(undefined);

registerNewCommands(
context as never,
log,
{ showDiff: vi.fn(), closeDiff } as never,
() => [],
vi.fn() as never,
);

await getRegisteredHandler(closeDiffCommand)('src/foo.ts', 'req-42');

expect(closeDiff).toHaveBeenCalledWith(
'/workspace/src/foo.ts',
true,
'req-42',
);
});

it('showDiff forwards the readOnly flag', async () => {
workspaceMock.workspaceFolders = [
{ uri: { fsPath: '/workspace' }, name: 'workspace', index: 0 },
Expand Down
253 changes: 253 additions & 0 deletions packages/vscode-ide-companion/src/diff-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ vi.mock('vscode', () => ({
fire = (e: unknown) => {
for (const listener of this.listeners) listener(e);
};
dispose = () => {
this.listeners.length = 0;
};
},
}));

Expand Down Expand Up @@ -217,6 +220,25 @@ describe('DiffManager path resolution', () => {
const WRITABLE_COMMAND =
'workbench.action.files.setActiveEditorWriteableInSession';

// `vscode.diff` is called as (command, left, right, title, options): the left
// side is the read-only old document, the right side the writable one the vote
// commands and the dismissal keying both resolve through.
function openedDiffCall(): unknown[] {
const call = executeCommand.mock.calls.find(
([command]) => command === 'vscode.diff',
);
if (!call) throw new Error('no diff was opened');
return call;
}

function lastOpenedLeftUri(): { toString(): string } {
return openedDiffCall()[1] as { toString(): string };
}

function lastOpenedRightUri(): { toString(): string } {
return openedDiffCall()[2] as { toString(): string };
}

describe('DiffManager.showDiff writability', () => {
beforeEach(() => {
executeCommand.mockClear();
Expand Down Expand Up @@ -323,3 +345,234 @@ describe('DiffManager.showDiff reuse', () => {
expect(diffOpenCount()).toBe(0);
});
});

describe('DiffManager permission diff dismissal', () => {
beforeEach(() => {
executeCommand.mockClear();
openTextDocument.mockResolvedValue({ getText: () => 'new content' });
});

function createManager(): InstanceType<typeof DiffManager> {
return new DiffManager(() => {}, new DiffContentProvider());
}

it('reports a permission diff the user closed without voting', async () => {
const manager = createManager();
const closed = vi.fn();
manager.onDidClosePermissionDiff(closed);

await manager.showDiff('/workspace/foo.ts', 'old', 'new', {
readOnly: true,
permissionRequestId: 'req-1',
});
await manager.cancelDiff(lastOpenedRightUri() as never);

// Deep equality on purpose: the fan-out in extension.ts consumes only the
// request id, so a field added here without a reader would be dead weight
// and this assertion is what stops one creeping back in.
expect(closed).toHaveBeenCalledWith({ permissionRequestId: 'req-1' });
});

it('stays quiet for a diff that no approval is waiting on', async () => {
const manager = createManager();
const closed = vi.fn();
manager.onDidClosePermissionDiff(closed);

await manager.showDiff('/workspace/foo.ts', 'old', 'new');
await manager.cancelDiff(lastOpenedRightUri() as never);

expect(closed).not.toHaveBeenCalled();
});

it('does not echo a close the chat surface asked for', async () => {
const manager = createManager();
const closed = vi.fn();
manager.onDidClosePermissionDiff(closed);

await manager.showDiff('/workspace/foo.ts', 'old', 'new', {
readOnly: true,
permissionRequestId: 'req-1',
});
const rightUri = lastOpenedRightUri();
// closeDiff() drops the entry before the tab closes, so the
// onDidCloseTextDocument -> cancelDiff hop that follows finds nothing.
await manager.closeDiff('/workspace/foo.ts', false, 'req-1');
await manager.cancelDiff(rightUri as never);

expect(closed).not.toHaveBeenCalled();
});

// R4-1: the IDE-mode MCP tool closes by path alone, so its close lands on a
// diff an approval owns while the caller knows nothing about that request.
it('reports a permission diff an id-less close took away', async () => {
Comment thread
yiliang114 marked this conversation as resolved.
const manager = createManager();
const closed = vi.fn();
manager.onDidClosePermissionDiff(closed);

await manager.showDiff('/workspace/foo.ts', 'old', 'new', {
readOnly: true,
permissionRequestId: 'req-1',
});
const rightUri = lastOpenedRightUri();

await manager.closeDiff('/workspace/foo.ts', true);
// The entry is already gone, so this hop cannot be what reports it.
await manager.cancelDiff(rightUri as never);
Comment thread
yiliang114 marked this conversation as resolved.

expect(closed).toHaveBeenCalledWith({ permissionRequestId: 'req-1' });
expect(closed).toHaveBeenCalledTimes(1);
});

// R5-2: the two cases above both pass `suppressNotification = true`, and the
// one case that passes `false` also passes a request id, so it never reaches
// the fire. The default arm is the one production actually sends —
// IdeClient.disconnect() calls closeDiff(filePath) with no options and the MCP
// closeDiff tool forwards `suppressNotification: undefined` — so gating the
// fire on the flag instead of on the missing id went unnoticed.
it('reports an id-less close that leaves the notification flag at its default', async () => {
const manager = createManager();
const closed = vi.fn();
manager.onDidClosePermissionDiff(closed);

await manager.showDiff('/workspace/foo.ts', 'old', 'new', {
readOnly: true,
permissionRequestId: 'req-1',
});

await manager.closeDiff('/workspace/foo.ts');

expect(closed).toHaveBeenCalledWith({ permissionRequestId: 'req-1' });
expect(closed).toHaveBeenCalledTimes(1);
});

it('stays quiet when an id-less close matches a diff no approval owns', async () => {
const manager = createManager();
const closed = vi.fn();
manager.onDidClosePermissionDiff(closed);

await manager.showDiff('/workspace/foo.ts', 'old', 'new');

await manager.closeDiff('/workspace/foo.ts', true);

expect(closed).not.toHaveBeenCalled();
});

it('stops notifying once the manager is disposed', async () => {
const manager = createManager();
const closed = vi.fn();
manager.onDidClosePermissionDiff(closed);

await manager.showDiff('/workspace/foo.ts', 'old', 'new', {
readOnly: true,
permissionRequestId: 'req-1',
});
const rightUri = lastOpenedRightUri();

// An extension-host reload activates a second manager while the previous
// one's listener closure still holds the torn-down provider registry.
manager.dispose();
await manager.cancelDiff(rightUri as never);

expect(closed).not.toHaveBeenCalled();
});
});

// R3-9: the request-id binding was landed without a witness for any of its three
// halves — stored by showDiff, read back by getPermissionRequestId/hasDiff, and
// used by closeDiff to refuse a diff owned by a different approval.
describe('DiffManager permission request id binding', () => {
beforeEach(() => {
executeCommand.mockClear();
openTextDocument.mockResolvedValue({ getText: () => 'new content' });
});

function createManager(): InstanceType<typeof DiffManager> {
return new DiffManager(() => {}, new DiffContentProvider());
}

it('reads back the request id the diff was opened for', async () => {
const manager = createManager();

await manager.showDiff('/workspace/foo.ts', 'old', 'new', {
readOnly: true,
permissionRequestId: 'req-1',
});
const rightUri = lastOpenedRightUri();

expect(manager.hasDiff(rightUri as never)).toBe(true);
expect(manager.getPermissionRequestId(rightUri as never)).toBe('req-1');
});

// R5-1: the entry is keyed on the writable right side only. `qwen.diff.accept`
// and `qwen.diff.cancel` resolve the vote through the active editor's uri,
// which is the modified side, so keying the map on the left document would
// silently break both — and while the uri mock rendered a `with()` copy with
// the original's scheme and query, both sides shared one key and nothing here
// could tell the two apart.
it('keys the diff on the writable side, not the read-only one', async () => {
const manager = createManager();

await manager.showDiff('/workspace/foo.ts', 'old', 'new', {
readOnly: true,
permissionRequestId: 'req-1',
});
const leftUri = lastOpenedLeftUri();

expect(manager.hasDiff(leftUri as never)).toBe(false);
expect(manager.getPermissionRequestId(leftUri as never)).toBeUndefined();
});

it('leaves the request id undefined for a diff no approval owns', async () => {
const manager = createManager();

await manager.showDiff('/workspace/foo.ts', 'old', 'new');
const rightUri = lastOpenedRightUri();

expect(manager.hasDiff(rightUri as never)).toBe(true);
expect(manager.getPermissionRequestId(rightUri as never)).toBeUndefined();
});

it('refuses to close a diff owned by a different approval', async () => {
const manager = createManager();

await manager.showDiff('/workspace/foo.ts', 'old', 'new', {
readOnly: true,
permissionRequestId: 'req-1',
});
const rightUri = lastOpenedRightUri();

await manager.closeDiff('/workspace/foo.ts', true, 'req-other');

// Same path, different owner: the diff the other approval is waiting on
// must survive.
expect(manager.hasDiff(rightUri as never)).toBe(true);
});

it('closes the bound diff when the ids match', async () => {
const manager = createManager();

await manager.showDiff('/workspace/foo.ts', 'old', 'new', {
readOnly: true,
permissionRequestId: 'req-1',
});
const rightUri = lastOpenedRightUri();

await manager.closeDiff('/workspace/foo.ts', true, 'req-1');

expect(manager.hasDiff(rightUri as never)).toBe(false);
});

it('closes by path alone when no request id is given', async () => {
const manager = createManager();

await manager.showDiff('/workspace/foo.ts', 'old', 'new', {
readOnly: true,
permissionRequestId: 'req-1',
});
const rightUri = lastOpenedRightUri();

await manager.closeDiff('/workspace/foo.ts', true);

expect(manager.hasDiff(rightUri as never)).toBe(false);
});
});
38 changes: 38 additions & 0 deletions packages/vscode-ide-companion/src/diff-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,24 @@ export class DiffManager {
private readonly onDidChangeEmitter =
new vscode.EventEmitter<JSONRPCNotification>();
readonly onDidChange = this.onDidChangeEmitter.event;

private readonly onDidClosePermissionDiffEmitter = new vscode.EventEmitter<{
permissionRequestId: string;
}>();
/**
* Fires when a diff opened for a pending permission is closed without a vote.
Comment thread
yiliang114 marked this conversation as resolved.
*
* `onDidChange` carries `ide/diffClosed` to IDE-mode MCP transports, which no
* web-shell host is; a chat surface that opened the diff itself would
* otherwise never learn the user closed it, and would keep waiting for a
* decision on an edit the user can no longer see (#10557). `qwen.diff.accept`
* and `qwen.diff.cancel` do not reach here for a request-bound diff — they
* route the vote through `respondToPendingPermission` instead — and
* `closeDiffEditor` drops the entry before the tab closes, so a close the
* chat surface asked for does not echo back as a dismissal.
*/
readonly onDidClosePermissionDiff =
this.onDidClosePermissionDiffEmitter.event;
private diffDocuments = new Map<string, DiffInfo>();
private readonly subscriptions: vscode.Disposable[] = [];
// Dedupe: remember recent showDiff calls keyed by (file+content)
Expand Down Expand Up @@ -158,6 +176,7 @@ export class DiffManager {
for (const subscription of this.subscriptions) {
subscription.dispose();
}
this.onDidClosePermissionDiffEmitter.dispose();
Comment thread
yiliang114 marked this conversation as resolved.
}

/**
Expand Down Expand Up @@ -406,6 +425,19 @@ export class DiffManager {
const rightDoc = await vscode.workspace.openTextDocument(uriToClose);
const modifiedContent = rightDoc.getText();
await this.closeDiffEditor(uriToClose);
// An id-less close matches by path alone: that caller does not know an
// approval owns this diff, so it cannot tell the surface holding the
// request. Fire the dismissal here, because closeDiffEditor already
// dropped the entry and the onDidCloseTextDocument -> cancelDiff hop that
// follows finds nothing (#10557 through a second door: an IDE-mode CLI
// closing the tab leaves the shell locked on a diff that is gone). A
// caller that passed the id *is* that surface and has cleared its own
// state, so its close must not echo back.
if (permissionRequestId === undefined && openDiff.permissionRequestId) {
this.onDidClosePermissionDiffEmitter.fire({
permissionRequestId: openDiff.permissionRequestId,
});
}
if (!suppressNotification) {
this.onDidChangeEmitter.fire(
IdeDiffClosedNotificationSchema.parse({
Expand Down Expand Up @@ -486,6 +518,12 @@ export class DiffManager {
},
}),
);

if (diffInfo.permissionRequestId) {
this.onDidClosePermissionDiffEmitter.fire({
permissionRequestId: diffInfo.permissionRequestId,
});
}
}

private async onActiveEditorChange(editor: vscode.TextEditor | undefined) {
Expand Down
Loading
Loading