Skip to content

Commit 884ebf8

Browse files
authored
Add Ruby Copilot chat agent with domain driven design command (#2366)
* Bump minimum VS Code engine to 1.92 * Add chat agent with domain driven design command * Apply PR feedback
1 parent c7d0edb commit 884ebf8

File tree

5 files changed

+299
-11
lines changed

5 files changed

+299
-11
lines changed

vscode/README.md

+25
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,31 @@ See complete information about features [here](https://shopify.github.io/ruby-ls
2929
If you experience issues, please see the [troubleshooting
3030
guide](https://github.com/Shopify/ruby-lsp/blob/main/TROUBLESHOOTING.md).
3131

32+
### [Experimental] GitHub Copilot chat agent
33+
34+
For users of Copilot, the Ruby LSP contributes a Ruby agent for AI assisted development of Ruby applications. Below you
35+
can find the documentation of each command for the Ruby agent. For information about how to interact with Copilot Chat,
36+
check [VS Code's official documentation](https://code.visualstudio.com/docs/copilot/copilot-chat).
37+
38+
#### Design command
39+
40+
The `@ruby /design` command is intended to be a domain driven design expert to help users model concepts for their Rails
41+
applications. Users should describe what type of application they are building and which concept they are trying to
42+
model. The command will read their Rails application's schema and use their prompt, previous interactions and the schema
43+
information to provide suggestions of how to design the application. For example,
44+
45+
```
46+
@ruby /design I'm working on a web application for schools. How do I model courses? And how do they relate to students?
47+
```
48+
49+
The output is a suggested schema for courses including relationships with users. In the chat window, two buttons will appear: `Generate with Rails`, which invokes the Rails generators to create the models suggested, and `Revert previous generation`, which will delete files generated by a previous click in the generate button.
50+
51+
As with most LLM chat functionality, suggestions may not be fully accurate, especially in the first iteration. Users can
52+
continue chatting with the `@ruby` agent to fine tune the suggestions given, before deciding to move forward with
53+
generation.
54+
55+
If you have feedback about this feature, you can let us know in the [DX Slack](https://join.slack.com/t/ruby-dx/shared_invite/zt-2c8zjlir6-uUDJl8oIwcen_FS_aA~b6Q) or by [creating an issue](https://github.com/Shopify/ruby-lsp/issues/new).
56+
3257
## Usage
3358

3459
Search for `Shopify.ruby-lsp` in the extensions tab and click install.

vscode/package.json

+20-3
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@
1111
"license": "MIT",
1212
"icon": "icon.png",
1313
"engines": {
14-
"vscode": "^1.68.0"
14+
"vscode": "^1.92.0"
1515
},
1616
"categories": [
1717
"Programming Languages",
1818
"Snippets",
19-
"Testing"
19+
"Testing",
20+
"AI",
21+
"Chat"
2022
],
2123
"activationEvents": [
2224
"onLanguage:ruby",
@@ -26,6 +28,21 @@
2628
],
2729
"main": "./out/extension.js",
2830
"contributes": {
31+
"chatParticipants": [
32+
{
33+
"id": "rubyLsp.chatAgent",
34+
"fullName": "Ruby",
35+
"name": "ruby",
36+
"description": "How can I help with your Ruby on Rails application?",
37+
"isSticky": true,
38+
"commands": [
39+
{
40+
"name": "design",
41+
"description": "Explain what you're trying to build and I will suggest possible ways to model the domain"
42+
}
43+
]
44+
}
45+
],
2946
"menus": {
3047
"editor/context": [
3148
{
@@ -651,7 +668,7 @@
651668
"@types/mocha": "^10.0.7",
652669
"@types/node": "22.x",
653670
"@types/sinon": "^17.0.3",
654-
"@types/vscode": "^1.68.0",
671+
"@types/vscode": "^1.92.0",
655672
"@typescript-eslint/eslint-plugin": "^7.18.0",
656673
"@typescript-eslint/parser": "^7.18.0",
657674
"@vscode/test-electron": "^2.4.1",

vscode/src/chatAgent.ts

+230
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import * as vscode from "vscode";
2+
3+
import { Command } from "./common";
4+
import { Workspace } from "./workspace";
5+
6+
const CHAT_AGENT_ID = "rubyLsp.chatAgent";
7+
const DESIGN_PROMPT = `
8+
You are a domain driven design and Ruby on Rails expert.
9+
The user will provide you with details about their Rails application.
10+
The user will ask you to help model a single specific concept.
11+
12+
Analyze the provided concept carefully and think step by step. Consider the following aspects:
13+
1. The core purpose of the concept
14+
2. Its relationships with other potential entities in the system
15+
3. The attributes that would best represent this concept in a database
16+
17+
Based on your analysis, suggest an appropriate model name and attributes to effectively model the concept.
18+
Follow these guidelines:
19+
20+
1. Choose a clear, singular noun for the model name that accurately represents the concept
21+
2. Select attributes that capture the essential characteristics of the concept
22+
3. Use appropriate data types for each attribute (e.g. string, integer, datetime, boolean)
23+
4. Consider adding foreign keys for relationships with other models, if applicable
24+
25+
After determining the model structure, generate the Rails commands to create the model and any associated resources.
26+
Include all relevant \`generate\` commands in a single Markdown shell code block at the end of your response.
27+
28+
The \`generate\` commands should ONLY include the type of generator and arguments, not the \`rails generate\` part
29+
(e.g.: \`model User name:string\` but not \`rails generate model User name:string\`).
30+
NEVER include commands to migrate the database as part of the code block.
31+
NEVER include redundant commands (e.g. including the migration and model generation commands for the same model).
32+
`.trim();
33+
34+
export class ChatAgent implements vscode.Disposable {
35+
private readonly agent: vscode.ChatParticipant;
36+
private readonly showWorkspacePick: () => Promise<Workspace | undefined>;
37+
38+
constructor(
39+
context: vscode.ExtensionContext,
40+
showWorkspacePick: () => Promise<Workspace | undefined>,
41+
) {
42+
this.agent = vscode.chat.createChatParticipant(
43+
CHAT_AGENT_ID,
44+
this.handler.bind(this),
45+
);
46+
this.agent.iconPath = vscode.Uri.joinPath(context.extensionUri, "icon.png");
47+
this.showWorkspacePick = showWorkspacePick;
48+
}
49+
50+
dispose() {
51+
this.agent.dispose();
52+
}
53+
54+
// Handle a new chat message or command
55+
private async handler(
56+
request: vscode.ChatRequest,
57+
context: vscode.ChatContext,
58+
stream: vscode.ChatResponseStream,
59+
token: vscode.CancellationToken,
60+
) {
61+
if (this.withinConversation("design", request, context)) {
62+
return this.runDesignCommand(request, context, stream, token);
63+
}
64+
65+
stream.markdown(
66+
"Please indicate which command you would like to use for our chat.",
67+
);
68+
return { metadata: { command: "" } };
69+
}
70+
71+
// Logic for the domain driven design command
72+
private async runDesignCommand(
73+
request: vscode.ChatRequest,
74+
context: vscode.ChatContext,
75+
stream: vscode.ChatResponseStream,
76+
token: vscode.CancellationToken,
77+
) {
78+
const previousInteractions = this.previousInteractions(context);
79+
const messages = [
80+
vscode.LanguageModelChatMessage.User(`User prompt: ${request.prompt}`),
81+
vscode.LanguageModelChatMessage.User(DESIGN_PROMPT),
82+
vscode.LanguageModelChatMessage.User(
83+
`Previous interactions with the user: ${previousInteractions}`,
84+
),
85+
];
86+
const workspace = await this.showWorkspacePick();
87+
88+
// On the first interaction with the design command, we gather the application's schema and include it as part of
89+
// the prompt
90+
if (request.command && workspace) {
91+
const schema = await this.schema(workspace);
92+
93+
if (schema) {
94+
messages.push(
95+
vscode.LanguageModelChatMessage.User(
96+
`Existing application schema: ${schema}`,
97+
),
98+
);
99+
}
100+
}
101+
102+
try {
103+
// Select the LLM model
104+
const [model] = await vscode.lm.selectChatModels({
105+
vendor: "copilot",
106+
family: "gpt-4o",
107+
});
108+
109+
stream.progress("Designing the models for the requested concept...");
110+
const chatResponse = await model.sendRequest(messages, {}, token);
111+
112+
let response = "";
113+
for await (const fragment of chatResponse.text) {
114+
// Maybe show the buttons here and display multiple shell blocks?
115+
stream.markdown(fragment);
116+
response += fragment;
117+
}
118+
119+
const match = /(?<=```shell)[^.$]*(?=```)/.exec(response);
120+
121+
if (workspace && match && match[0]) {
122+
// The shell code block includes all of the `rails generate` commands. We need to strip out the `rails generate`
123+
// from all of them since our commands only accept from the generator forward
124+
const commandList = match[0]
125+
.trim()
126+
.split("\n")
127+
.map((command) => {
128+
return command.replace(/\s*(bin\/rails|rails) generate\s*/, "");
129+
});
130+
131+
stream.button({
132+
command: Command.RailsGenerate,
133+
title: "Generate with Rails",
134+
arguments: [commandList, workspace],
135+
});
136+
137+
stream.button({
138+
command: Command.RailsDestroy,
139+
title: "Revert previous generation",
140+
arguments: [commandList, workspace],
141+
});
142+
}
143+
} catch (err) {
144+
this.handleError(err, stream);
145+
}
146+
147+
return { metadata: { command: "design" } };
148+
}
149+
150+
private async schema(workspace: Workspace) {
151+
try {
152+
const content = await vscode.workspace.fs.readFile(
153+
vscode.Uri.joinPath(workspace.workspaceFolder.uri, "db/schema.rb"),
154+
);
155+
return content.toString();
156+
} catch (error) {
157+
// db/schema.rb doesn't exist
158+
}
159+
160+
try {
161+
const content = await vscode.workspace.fs.readFile(
162+
vscode.Uri.joinPath(workspace.workspaceFolder.uri, "db/structure.sql"),
163+
);
164+
return content.toString();
165+
} catch (error) {
166+
// db/structure.sql doesn't exist
167+
}
168+
169+
return undefined;
170+
}
171+
172+
// Returns `true` if the current or any previous interactions with the chat match the given `command`. Useful for
173+
// ensuring that the user can continue chatting without having to re-type the desired command multiple times
174+
private withinConversation(
175+
command: string,
176+
request: vscode.ChatRequest,
177+
context: vscode.ChatContext,
178+
) {
179+
return (
180+
request.command === command ||
181+
(!request.command &&
182+
context.history.some(
183+
(entry) =>
184+
entry instanceof vscode.ChatRequestTurn &&
185+
entry.command === command,
186+
))
187+
);
188+
}
189+
190+
// Default error handling
191+
private handleError(err: any, stream: vscode.ChatResponseStream) {
192+
if (err instanceof vscode.LanguageModelError) {
193+
if (
194+
err.cause instanceof Error &&
195+
err.cause.message.includes("off_topic")
196+
) {
197+
stream.markdown(
198+
"Sorry, I can only help you with Ruby related questions",
199+
);
200+
}
201+
} else {
202+
throw err;
203+
}
204+
}
205+
206+
// Get the content of all previous interactions (including requests and responses) as a string
207+
private previousInteractions(context: vscode.ChatContext): string {
208+
let history = "";
209+
210+
context.history.forEach((entry) => {
211+
if (entry instanceof vscode.ChatResponseTurn) {
212+
if (entry.participant === CHAT_AGENT_ID) {
213+
let content = "";
214+
215+
entry.response.forEach((part) => {
216+
if (part instanceof vscode.ChatResponseMarkdownPart) {
217+
content += part.value.value;
218+
}
219+
});
220+
221+
history += `Response: ${content}`;
222+
}
223+
} else {
224+
history += `Request: ${entry.prompt}`;
225+
}
226+
});
227+
228+
return history;
229+
}
230+
}

vscode/src/rubyLsp.ts

+20-4
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { newMinitestFile, openFile, openUris } from "./commands";
1616
import { Debugger } from "./debugger";
1717
import { DependenciesTree } from "./dependenciesTree";
1818
import { Rails } from "./rails";
19+
import { ChatAgent } from "./chatAgent";
1920

2021
// The RubyLsp class represents an instance of the entire extension. This should only be instantiated once at the
2122
// activation event. One instance of this class controls all of the existing workspaces, telemetry and handles all
@@ -50,6 +51,7 @@ export class RubyLsp {
5051
this.statusItems,
5152
this.debug,
5253
dependenciesTree,
54+
new ChatAgent(context, this.showWorkspacePick.bind(this)),
5355

5456
// Switch the status items based on which workspace is currently active
5557
vscode.window.onDidChangeActiveTextEditor((editor) => {
@@ -457,7 +459,7 @@ export class RubyLsp {
457459
vscode.commands.registerCommand(
458460
Command.RailsGenerate,
459461
async (
460-
generatorWithArguments: string | undefined,
462+
generatorWithArguments: string | string[] | undefined,
461463
workspace: Workspace | undefined,
462464
) => {
463465
// If the command was invoked programmatically, then the arguments will already be present. Otherwise, we need
@@ -474,13 +476,20 @@ export class RubyLsp {
474476
return;
475477
}
476478

477-
await this.rails.generate(command, workspace);
479+
if (typeof command === "string") {
480+
await this.rails.generate(command, workspace);
481+
return;
482+
}
483+
484+
for (const generate of command) {
485+
await this.rails.generate(generate, workspace);
486+
}
478487
},
479488
),
480489
vscode.commands.registerCommand(
481490
Command.RailsDestroy,
482491
async (
483-
generatorWithArguments: string | undefined,
492+
generatorWithArguments: string | string[] | undefined,
484493
workspace: Workspace | undefined,
485494
) => {
486495
// If the command was invoked programmatically, then the arguments will already be present. Otherwise, we need
@@ -497,7 +506,14 @@ export class RubyLsp {
497506
return;
498507
}
499508

500-
await this.rails.destroy(command, workspace);
509+
if (typeof command === "string") {
510+
await this.rails.destroy(command, workspace);
511+
return;
512+
}
513+
514+
for (const generate of command) {
515+
await this.rails.destroy(generate, workspace);
516+
}
501517
},
502518
),
503519
vscode.commands.registerCommand(Command.FileOperation, async () => {

vscode/yarn.lock

+4-4
Original file line numberDiff line numberDiff line change
@@ -715,10 +715,10 @@
715715
resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz#5fd3592ff10c1e9695d377020c033116cc2889f2"
716716
integrity sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==
717717

718-
"@types/vscode@^1.68.0":
719-
version "1.86.0"
720-
resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.86.0.tgz#5d5f233137b27e51d7ad1462600005741296357a"
721-
integrity sha512-DnIXf2ftWv+9LWOB5OJeIeaLigLHF7fdXF6atfc7X5g2w/wVZBgk0amP7b+ub5xAuW1q7qP5YcFvOcit/DtyCQ==
718+
"@types/vscode@^1.92.0":
719+
version "1.92.0"
720+
resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.92.0.tgz#b4d6bc180e7206defe643a1a5f38a1367947d418"
721+
integrity sha512-DcZoCj17RXlzB4XJ7IfKdPTcTGDLYvTOcTNkvtjXWF+K2TlKzHHkBEXNWQRpBIXixNEUgx39cQeTFunY0E2msw==
722722

723723
"@typescript-eslint/eslint-plugin@^7.18.0", "@typescript-eslint/eslint-plugin@^7.9.0":
724724
version "7.18.0"

0 commit comments

Comments
 (0)