Skip to content

Commit d4e0950

Browse files
abeatrixolafurpg
andauthored
Agent: Custom Commands support (#3089)
Add Custom Commands support to Agent. - This includes getting the commands from the `.cody/commands.json` file. - vs code client should migrate from using `.vscode/cody.json` to `.cody/commands.json`, will do this in follow up - Execute a custom command in either chat mode or inline edit mode. Current supported context options for Custom Commands in agent covered by the agent tests: - none: exclude all context - current directory - open tabs - current file - selection ## Test plan <!-- Required. See https://sourcegraph.com/docs/dev/background-information/testing_principles. --> CI should be green with the newly added tests for Custom Commands. --------- Co-authored-by: Olafur Geirsson <olafurpg@gmail.com>
1 parent 01dd3de commit d4e0950

File tree

14 files changed

+11047
-2483
lines changed

14 files changed

+11047
-2483
lines changed

agent/recordings/defaultClient_631904893/recording.har.yaml

Lines changed: 10159 additions & 2440 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

agent/recordings/enterpriseClient_3965582033/recording.har.yaml

Lines changed: 515 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"commands": {
3+
"translate": {
4+
"prompt": "Translate the selected code into:",
5+
"context": {
6+
"selection": true
7+
}
8+
},
9+
"hello": {
10+
"prompt": "Add a 'hello' comment for the selected code, without including the selected code.",
11+
"context": {
12+
"selection": true
13+
},
14+
"mode": "insert"
15+
},
16+
"newField": {
17+
"prompt": "Add a new field to the class that console log the name of the animal.",
18+
"context": {
19+
"currentFile": true,
20+
"selection": false
21+
},
22+
"mode": "edit"
23+
},
24+
"none": {
25+
"prompt": "Did I share any code with you? If yes, reply single word 'yes'. If none, reply 'no'.",
26+
"context": {
27+
"none": true
28+
}
29+
},
30+
"countDirFiles": {
31+
"prompt": "How many file context have I shared with you?",
32+
"context": {
33+
"currentDir": true
34+
}
35+
},
36+
"countTabs": {
37+
"prompt": "Give me the names of the files I have shared with you so far.",
38+
"context": {
39+
"openTabs": true
40+
}
41+
}
42+
}
43+
}

agent/src/agent.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import type {
3737
AutocompleteItem,
3838
ClientInfo,
3939
CodyError,
40+
CustomCommandResult,
4041
EditTask,
4142
ExtensionConfiguration,
4243
TextEdit,
@@ -683,6 +684,16 @@ export class Agent extends MessageHandler {
683684
)
684685
})
685686

687+
this.registerAuthenticatedRequest('commands/custom', ({ key }) => {
688+
return this.executeCustomCommand(
689+
vscode.commands.executeCommand<CommandResult | undefined>(
690+
'cody.action.command',
691+
key,
692+
commandArgs
693+
)
694+
)
695+
})
696+
686697
this.registerAuthenticatedRequest('commands/document', () => {
687698
return this.createEditTask(
688699
vscode.commands.executeCommand<CommandResult | undefined>('cody.command.document-code')
@@ -1010,6 +1021,22 @@ export class Agent extends MessageHandler {
10101021
return webviewPanel.panelID
10111022
}
10121023

1024+
private async executeCustomCommand(
1025+
commandResult: Thenable<CommandResult | undefined>
1026+
): Promise<CustomCommandResult> {
1027+
const result = (await commandResult) ?? { type: 'empty-command-result' }
1028+
1029+
if (result?.type === 'chat') {
1030+
return { type: 'chat', chatResult: await this.createChatPanel(commandResult) }
1031+
}
1032+
1033+
if (result?.type === 'edit') {
1034+
return { type: 'edit', editResult: await this.createEditTask(commandResult) }
1035+
}
1036+
1037+
throw new Error('Invalid custom command result')
1038+
}
1039+
10131040
// Alternative to `registerRequest` that awaits on authentication changes to
10141041
// propagate before calling the method handler.
10151042
public registerAuthenticatedRequest<M extends RequestMethodName>(

agent/src/index.test.ts

Lines changed: 190 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { URI } from 'vscode-uri'
1313
import { isNode16 } from './isNode16'
1414
import { TestClient, asTranscriptMessage } from './TestClient'
1515
import { decodeURIs } from './decodeURIs'
16+
import type { CustomChatCommandResult, CustomEditCommandResult, EditTask } from './protocol-alias'
1617

1718
const explainPollyError = `
1819
@@ -697,58 +698,60 @@ describe('Agent', () => {
697698
await client.openFile(animalUri)
698699
const id = await client.request('commands/test', null)
699700
const lastMessage = await client.firstNonEmptyTranscript(id)
700-
expect(trimEndOfLine(lastMessage.messages.at(-1)?.text ?? '')).toMatchInlineSnapshot(
701-
`
702-
" Okay, reviewing the shared context, it looks like there are no existing test files provided.
701+
expect(trimEndOfLine(lastMessage.messages.at(-1)?.text ?? '')).toMatchInlineSnapshot(`
702+
" Okay, based on the shared context, I see that Vitest is being used as the test framework. No mocks are detected.
703703
704-
Since \`src/animal.ts\` defines an \`Animal\` interface, I will generate Jest unit tests for this interface in \`src/animal.test.ts\`:
704+
Since there are no existing tests for the Animal interface, I will generate a new test file with sample unit tests covering basic validation of the Animal interface:
705705
706706
\`\`\`typescript
707-
// src/animal.test.ts
707+
import { expect } from 'vitest'
708708
709-
import { Animal } from './animal';
709+
import { describe, it } from 'vitest'
710710
711-
describe('Animal interface', () => {
711+
import { Animal } from './animal'
712712
713-
it('should have a name property', () => {
713+
describe('Animal', () => {
714+
715+
it('has name property', () => {
714716
const animal: Animal = {
715717
name: 'Cat',
716-
makeAnimalSound: () => '',
718+
makeAnimalSound() {
719+
return 'Meow'
720+
},
717721
isMammal: true
718-
};
722+
}
719723
720-
expect(animal.name).toBeDefined();
721-
});
724+
expect(animal.name).toEqual('Cat')
725+
})
722726
723-
it('should have a makeAnimalSound method', () => {
727+
it('has makeAnimalSound method', () => {
724728
const animal: Animal = {
725729
name: 'Dog',
726-
makeAnimalSound: () => 'Woof',
730+
makeAnimalSound() {
731+
return 'Woof'
732+
},
727733
isMammal: true
728-
};
734+
}
729735
730-
expect(animal.makeAnimalSound).toBeDefined();
731-
expect(typeof animal.makeAnimalSound).toBe('function');
732-
});
736+
expect(animal.makeAnimalSound()).toEqual('Woof')
737+
})
733738
734-
it('should have an isMammal property', () => {
739+
it('has isMammal property', () => {
735740
const animal: Animal = {
736741
name: 'Snake',
737-
makeAnimalSound: () => 'Hiss',
742+
makeAnimalSound() {
743+
return 'Hiss'
744+
},
738745
isMammal: false
739-
};
740-
741-
expect(animal.isMammal).toBeDefined();
742-
expect(typeof animal.isMammal).toBe('boolean');
743-
});
746+
}
744747
745-
});
748+
expect(animal.isMammal).toEqual(false)
749+
})
750+
})
746751
\`\`\`
747752
748-
This covers basic validation of the Animal interface properties and methods using Jest assertions. Additional tests could validate more complex object shapes and logic."
749-
`,
750-
explainPollyError
751-
)
753+
This covers basic validation of the Animal interface properties and methods using Vitest assertions.Let me know if you would like me to expand on any additional test cases."
754+
`)
752755
},
753756
30_000
754757
)
@@ -976,6 +979,163 @@ describe('Agent', () => {
976979
})
977980
})
978981

982+
describe('Custom Commands', () => {
983+
it('commands/custom, chat command, open tabs context', async () => {
984+
await client.request('command/execute', {
985+
command: 'cody.search.index-update',
986+
})
987+
// Note: The test editor has all the files opened from previous tests as open tabs,
988+
// so we will need to open a new file that has not been opened before,
989+
// to make sure this context type is working.
990+
const trickyLogicPath = path.join(workspaceRootPath, 'src', 'trickyLogic.ts')
991+
const trickyLogicUri = vscode.Uri.file(trickyLogicPath)
992+
await client.openFile(trickyLogicUri)
993+
994+
const result = (await client.request('commands/custom', {
995+
key: '/countTabs',
996+
})) as CustomChatCommandResult
997+
expect(result.type).toBe('chat')
998+
const lastMessage = await client.firstNonEmptyTranscript(result?.chatResult as string)
999+
expect(trimEndOfLine(lastMessage.messages.at(-1)?.text ?? '')).toMatchInlineSnapshot(`
1000+
" So far you have shared code context from these files:
1001+
1002+
- src/trickyLogic.ts
1003+
- src/TestLogger.ts
1004+
- src/TestClass.ts
1005+
- src/sum.ts
1006+
- src/squirrel.ts
1007+
- src/multiple-selections.ts
1008+
- src/example.test.ts
1009+
- src/animal.ts
1010+
- .cody/ignore"
1011+
`)
1012+
}, 30_000)
1013+
1014+
it('commands/custom, chat command, adds argument', async () => {
1015+
await client.request('command/execute', {
1016+
command: 'cody.search.index-update',
1017+
})
1018+
await client.openFile(animalUri)
1019+
const result = (await client.request('commands/custom', {
1020+
key: '/translate Python',
1021+
})) as CustomChatCommandResult
1022+
expect(result.type).toBe('chat')
1023+
const lastMessage = await client.firstNonEmptyTranscript(result?.chatResult as string)
1024+
expect(trimEndOfLine(lastMessage.messages.at(-1)?.text ?? '')).toMatchInlineSnapshot(`
1025+
" Here is the TypeScript code translated to Python:
1026+
1027+
\`\`\`python
1028+
class Animal:
1029+
def __init__(self, name: str, is_mammal: bool):
1030+
self.name = name
1031+
self.is_mammal = is_mammal
1032+
1033+
def make_animal_sound(self) -> str:
1034+
pass
1035+
\`\`\`
1036+
1037+
The key differences:
1038+
1039+
- Interfaces don't exist in Python, so Animal is translated to a class
1040+
- The interface properties become initialized attributes in the __init__ method
1041+
- The interface method becomes a method in the class
1042+
- Python type hints are added for name, is_mammal, and the return type of make_animal_sound
1043+
1044+
Let me know if you have any other questions!"
1045+
`)
1046+
}, 30_000)
1047+
1048+
it('commands/custom, chat command, no context', async () => {
1049+
await client.request('command/execute', {
1050+
command: 'cody.search.index-update',
1051+
})
1052+
await client.openFile(animalUri)
1053+
const result = (await client.request('commands/custom', {
1054+
key: '/none',
1055+
})) as CustomChatCommandResult
1056+
expect(result.type).toBe('chat')
1057+
const lastMessage = await client.firstNonEmptyTranscript(result.chatResult as string)
1058+
expect(trimEndOfLine(lastMessage.messages.at(-1)?.text ?? '')).toMatchInlineSnapshot(`" no"`)
1059+
}, 30_000)
1060+
1061+
// The context files are presented in an order in the CI that is different
1062+
// than the order shown in recordings when on Windows, causing it to fail.
1063+
it('commands/custom, chat command, current directory context', async () => {
1064+
await client.request('command/execute', {
1065+
command: 'cody.search.index-update',
1066+
})
1067+
await client.openFile(animalUri)
1068+
const result = (await client.request('commands/custom', {
1069+
key: '/countDirFiles',
1070+
})) as CustomChatCommandResult
1071+
expect(result.type).toBe('chat')
1072+
const lastMessage = await client.firstNonEmptyTranscript(result.chatResult as string)
1073+
const reply = trimEndOfLine(lastMessage.messages.at(-1)?.text ?? '')
1074+
expect(reply).not.includes('.cody/ignore') // file that's not located in the src/directory
1075+
expect(reply).toMatchInlineSnapshot(`
1076+
" You have shared 7 file contexts with me so far:
1077+
1078+
1. src/trickyLogic.ts
1079+
2. src/TestLogger.ts
1080+
3. src/TestClass.ts
1081+
4. src/sum.ts
1082+
5. src/squirrel.ts
1083+
6. src/multiple-selections.ts
1084+
7. src/example.test.ts"
1085+
`)
1086+
}, 30_000)
1087+
1088+
it('commands/custom, edit command, insert mode', async () => {
1089+
await client.request('command/execute', {
1090+
command: 'cody.search.index-update',
1091+
})
1092+
await client.openFile(sumUri, { removeCursor: false })
1093+
const result = (await client.request('commands/custom', {
1094+
key: '/hello',
1095+
})) as CustomEditCommandResult
1096+
expect(result.type).toBe('edit')
1097+
await client.taskHasReachedAppliedPhase(result.editResult as EditTask)
1098+
1099+
const originalDocument = client.workspace.getDocument(sumUri)!
1100+
expect(trimEndOfLine(originalDocument.getText())).toMatchInlineSnapshot(`
1101+
"/** hello */
1102+
export function sum(a: number, b: number): number {
1103+
/* CURSOR */
1104+
}
1105+
"
1106+
`)
1107+
}, 30_000)
1108+
1109+
it('commands/custom, edit command, edit mode', async () => {
1110+
await client.request('command/execute', {
1111+
command: 'cody.search.index-update',
1112+
})
1113+
await client.openFile(animalUri)
1114+
1115+
const result = (await client.request('commands/custom', {
1116+
key: '/newField',
1117+
})) as CustomEditCommandResult
1118+
expect(result.type).toBe('edit')
1119+
await client.taskHasReachedAppliedPhase(result.editResult as EditTask)
1120+
1121+
const originalDocument = client.workspace.getDocument(animalUri)!
1122+
expect(trimEndOfLine(originalDocument.getText())).toMatchInlineSnapshot(`
1123+
"/* SELECTION_START */
1124+
export interface Animal {
1125+
name: string
1126+
makeAnimalSound(): string
1127+
isMammal: boolean
1128+
logName(): void {
1129+
console.log(this.name)
1130+
}
1131+
}
1132+
/* SELECTION_END */
1133+
1134+
"
1135+
`)
1136+
}, 30_000)
1137+
})
1138+
9791139
describe('Progress bars', () => {
9801140
it('progress/report', async () => {
9811141
const { result } = await client.request('testing/progress', {

agent/src/vscode-shim.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ const _workspace: typeof vscode.workspace = {
198198
logError('vscode.workspace.applyEdit', 'agent is undefined')
199199
return Promise.resolve(false)
200200
},
201-
isTrusted: false,
201+
isTrusted: true,
202202
name: undefined,
203203
notebookDocuments: [],
204204
openNotebookDocument: (() => {}) as any,
@@ -949,7 +949,14 @@ const _languages: Partial<typeof vscode.languages> = {
949949
resolveFirstCompletionProvider(provider as any)
950950
return emptyDisposable
951951
},
952+
getDiagnostics: ((resource: vscode.Uri) => {
953+
if (resource) {
954+
return [] as vscode.Diagnostic[] // return diagnostics for the specific resource
955+
}
956+
return [[resource, []]] // return diagnostics for all resources
957+
}) as { (resource: vscode.Uri): vscode.Diagnostic[]; (): [vscode.Uri, vscode.Diagnostic[]][] },
952958
}
959+
953960
export const languages = _languages as typeof vscode.languages
954961

955962
const commentController: vscode.CommentController = {

lib/shared/src/codebase-context/messages.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,14 @@ export function createContextMessageByFile(file: ContextFile, content: string):
106106
if (!code) {
107107
return []
108108
}
109+
const filepath = displayPath(file.uri)
109110
return [
110111
{
111112
speaker: 'human',
112113
text:
113114
file.type === 'file'
114-
? `Context from file path @${file.uri?.path}:\n${code}`
115-
: `$${file.symbolName} is a ${file.kind} symbol from file path @${displayPath(
116-
file.uri
117-
)}:\n${code}`,
115+
? `Context from file path @${filepath}:\n${code}`
116+
: `$${file.symbolName} is a ${file.kind} symbol from file path @${filepath}:\n${code}`,
118117
file,
119118
},
120119
{ speaker: 'assistant', text: 'OK.' },

0 commit comments

Comments
 (0)