Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
106 changes: 106 additions & 0 deletions packages/ai-bot/tests/prompt-construction-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2723,6 +2723,112 @@ Attached Files (files with newer versions don't show their content):
assert.ok(alertTool, 'Should have AlertTheUser function available');
});

test('switch-submode result appends a filename instruction to tool message', async function () {
const roomId = '!room:localhost';
const commandEventId = 'switch-submode-event';
const commandResultId = 'switch-submode-result';
const commandRequestId = 'switch-submode-cmd';
const requestedCodePath =
'http://localhost:4201/drafts/rock-paper-scissors.gts';
const finalCodePath =
'http://localhost:4201/drafts/rock-paper-scissors-1.gts';

const history: DiscreteMatrixEvent[] = [
{
type: 'm.room.message',
event_id: commandEventId,
room_id: roomId,
sender: '@aibot:localhost',
origin_server_ts: 1,
content: {
msgtype: APP_BOXEL_MESSAGE_MSGTYPE,
body: 'Switch to code',
format: 'org.matrix.custom.html',
isStreamingFinished: true,
[APP_BOXEL_COMMAND_REQUESTS_KEY]: [
{
id: commandRequestId,
name: 'switch-submode_1234',
arguments: JSON.stringify({
description: 'Switch to code submode',
attributes: {
submode: 'code',
codePath: requestedCodePath,
createFile: true,
},
}),
},
],
data: {
context: {
tools: [],
functions: [],
},
},
},
unsigned: { age: 0, transaction_id: commandEventId },
status: EventStatus.SENT,
},
{
type: APP_BOXEL_COMMAND_RESULT_EVENT_TYPE,
event_id: commandResultId,
room_id: roomId,
sender: '@command:localhost',
origin_server_ts: 2,
content: {
msgtype: APP_BOXEL_COMMAND_RESULT_WITH_OUTPUT_MSGTYPE,
commandRequestId,
'm.relates_to': {
event_id: commandEventId,
key: 'applied',
rel_type: APP_BOXEL_COMMAND_RESULT_REL_TYPE,
},
data: {
card: {
sourceUrl: finalCodePath,
url: finalCodePath,
name: 'switch-submode-result.json',
contentType: 'application/json',
content: JSON.stringify({
data: {
attributes: {
codePath: finalCodePath,
requestedCodePath,
},
meta: {
adoptsFrom: {
module: 'https://cardstack.com/base/command',
name: 'SwitchSubmodeResult',
},
},
},
}),
},
},
},
unsigned: { age: 0, transaction_id: commandResultId },
status: EventStatus.SENT,
},
];

const result = await buildPromptForModel(
history,
'@aibot:localhost',
[],
[],
[],
fakeMatrixClient,
);
const toolMessages = result.filter((message) => message.role === 'tool');
assert.strictEqual(toolMessages.length, 1, 'should have one tool message');
assert.ok(
(toolMessages[0].content as string).includes(
`Use ${finalCodePath} for the SEARCH/REPLACE block since ${requestedCodePath} already exists.`,
),
'tool message should include the filename instruction',
);
});

test('Tools are not required unless they are in the last message', async () => {
const eventList: DiscreteMatrixEvent[] = JSON.parse(
readFileSync(
Expand Down
6 changes: 6 additions & 0 deletions packages/base/command.gts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ export class FileContents extends CardDef {
export class SwitchSubmodeInput extends CardDef {
@field submode = contains(StringField);
@field codePath = contains(StringField);
@field createFile = contains(BooleanField);
}

export class SwitchSubmodeResult extends CardDef {
@field codePath = contains(StringField);
@field requestedCodePath = contains(StringField);
}

export class WriteTextFileInput extends CardDef {
Expand Down
33 changes: 5 additions & 28 deletions packages/host/app/commands/patch-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import HostBaseCommand from '../lib/host-base-command';
import { parseSearchReplace } from '../lib/search-replace-block-parsing';
import { isReady } from '../resources/file';

import { findNonConflictingFilename } from '../utils/file-name';

import ApplySearchReplaceBlockCommand from './apply-search-replace-block';
import LintAndFixCommand from './lint-and-fix';

Expand Down Expand Up @@ -218,34 +220,9 @@ export default class PatchCodeCommand extends HostBaseCommand<
return originalUrl;
}

return await this.findNonConflictingFilename(originalUrl);
}

private async findNonConflictingFilename(fileUrl: string): Promise<string> {
let MAX_ATTEMPTS = 100;
let { baseName, extension } = this.parseFilename(fileUrl);

for (let counter = 1; counter < MAX_ATTEMPTS; counter++) {
let candidateUrl = `${baseName}-${counter}${extension}`;
let exists = await this.fileExists(candidateUrl);

if (!exists) {
return candidateUrl;
}
}

return `${baseName}-${MAX_ATTEMPTS}${extension}`;
}

private parseFilename(fileUrl: string): {
baseName: string;
extension: string;
} {
let extensionMatch = fileUrl.match(/\.([^.]+)$/);
let extension = extensionMatch?.[0] || '';
let baseName = fileUrl.replace(/\.([^.]+)$/, '');

return { baseName, extension };
return await findNonConflictingFilename(originalUrl, (candidateUrl) =>
this.fileExists(candidateUrl),
);
}

private async fileExists(fileUrl: string): Promise<boolean> {
Expand Down
69 changes: 56 additions & 13 deletions packages/host/app/commands/switch-submode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,18 @@ import { Submodes } from '../components/submode-switcher';

import HostBaseCommand from '../lib/host-base-command';

import { findNonConflictingFilename } from '../utils/file-name';

import type CardService from '../services/card-service';
import type OperatorModeStateService from '../services/operator-mode-state-service';
import type StoreService from '../services/store';

export default class SwitchSubmodeCommand extends HostBaseCommand<
typeof BaseCommandModule.SwitchSubmodeInput
typeof BaseCommandModule.SwitchSubmodeInput,
typeof BaseCommandModule.SwitchSubmodeResult | undefined
> {
@service declare private operatorModeStateService: OperatorModeStateService;
@service declare private cardService: CardService;
@service declare private store: StoreService;

static actionVerb = 'Switch';
Expand Down Expand Up @@ -43,24 +48,55 @@ export default class SwitchSubmodeCommand extends HostBaseCommand<

protected async run(
input: BaseCommandModule.SwitchSubmodeInput,
): Promise<undefined> {
): Promise<BaseCommandModule.SwitchSubmodeResult | undefined> {
let resultCard: BaseCommandModule.SwitchSubmodeResult | undefined;
switch (input.submode) {
case Submodes.Interact:
await this.operatorModeStateService.updateCodePath(null);
break;
case Submodes.Code:
if (input.codePath) {
await this.operatorModeStateService.updateCodePath(
new URL(input.codePath),
);
} else {
await this.operatorModeStateService.updateCodePath(
this.lastCardInRightMostStack
? new URL(this.lastCardInRightMostStack + '.json')
: null,
);
case Submodes.Code: {
let codePath =
input.codePath ??
(this.lastCardInRightMostStack
? this.lastCardInRightMostStack + '.json'
: null);
let codeUrl = codePath ? new URL(codePath) : null;
let currentSubmode = this.operatorModeStateService.state.submode;
let finalCodeUrl = codeUrl;
if (
codeUrl &&
input.createFile &&
currentSubmode === Submodes.Interact
) {
let { status, content } = await this.cardService.getSource(codeUrl);
if (status === 404) {
await this.cardService.saveSource(codeUrl, '', 'create-file');
} else if (status === 200) {
if (content.trim() !== '') {
let nonConflictingUrl = await findNonConflictingFilename(
codeUrl.href,
(candidateUrl) => this.fileExists(candidateUrl),
);
let newCodeUrl = new URL(nonConflictingUrl);
await this.cardService.saveSource(newCodeUrl, '', 'create-file');
finalCodeUrl = newCodeUrl;

let commandModule = await this.loadCommandModule();
const { SwitchSubmodeResult } = commandModule;
resultCard = new SwitchSubmodeResult({
codePath: newCodeUrl.href,
requestedCodePath: codeUrl.href,
});
}
} else {
throw new Error(
`Error checking if file exists at ${codeUrl}: ${status}`,
);
}
}
await this.operatorModeStateService.updateCodePath(finalCodeUrl);
break;
}
default:
throw new Error(`invalid submode specified: ${input.submode}`);
}
Expand All @@ -69,5 +105,12 @@ export default class SwitchSubmodeCommand extends HostBaseCommand<
if (this.operatorModeStateService.workspaceChooserOpened) {
this.operatorModeStateService.closeWorkspaceChooser();
}

return resultCard;
}

private async fileExists(fileUrl: string): Promise<boolean> {
let getSourceResult = await this.cardService.getSource(new URL(fileUrl));
return getSourceResult.status !== 404;
}
}
4 changes: 3 additions & 1 deletion packages/host/app/components/matrix/room-message-command.gts
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,11 @@ export default class RoomMessageCommand extends Component<Signature> {
}

private get shouldDisplayResultCard() {
let commandName = this.args.messageCommand.name ?? '';
return (
!!this.commandResultCard.card &&
this.args.messageCommand.name !== 'checkCorrectness'
commandName !== 'checkCorrectness' &&
!commandName.startsWith('switch-submode')
);
}

Expand Down
38 changes: 38 additions & 0 deletions packages/host/app/utils/file-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
export async function findNonConflictingFilename(
fileUrl: string,
fileExists: (fileUrl: string) => Promise<boolean>,
): Promise<string> {
let maxAttempts = 100;
let { baseName, extension } = parseFilename(fileUrl);

for (let counter = 1; counter < maxAttempts; counter++) {
let candidateUrl = `${baseName}-${counter}${extension}`;
let exists = await fileExists(candidateUrl);

if (!exists) {
return candidateUrl;
}
}

const finalCandidateUrl = `${baseName}-${maxAttempts}${extension}`;
const finalExists = await fileExists(finalCandidateUrl);

if (!finalExists) {
return finalCandidateUrl;
}

throw new Error(
`Unable to find non-conflicting filename for "${fileUrl}" after ${maxAttempts} attempts.`,
);
}

function parseFilename(fileUrl: string): {
baseName: string;
extension: string;
} {
let extensionMatch = fileUrl.match(/\.([^.]+)$/);
let extension = extensionMatch?.[0] || '';
let baseName = fileUrl.replace(/\.([^.]+)$/, '');

return { baseName, extension };
}
Loading