feat(app): add @ workspace file autocomplete in agent chat

This commit is contained in:
Mohamed Boudra
2026-02-18 13:25:55 +07:00
parent b1955cf74d
commit d30bb84741
16 changed files with 7993 additions and 7680 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it } from 'vitest'
import {
AgentStreamMessageSchema,
@@ -6,172 +6,176 @@ import {
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
WSOutboundMessageSchema,
} from "./messages.js";
} from './messages.js'
describe("shared messages stream parsing", () => {
it("parses representative fetch_agent_timeline_response payload", () => {
describe('shared messages stream parsing', () => {
it('parses representative fetch_agent_timeline_response payload', () => {
const parsed = FetchAgentTimelineResponseMessageSchema.parse({
type: "fetch_agent_timeline_response",
type: 'fetch_agent_timeline_response',
payload: {
requestId: "req-1",
agentId: "agent_live",
direction: "tail",
projection: "projected",
epoch: "epoch-1",
requestId: 'req-1',
agentId: 'agent_live',
direction: 'tail',
projection: 'projected',
epoch: 'epoch-1',
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 2, nextSeq: 3 },
startCursor: { epoch: "epoch-1", seq: 1 },
endCursor: { epoch: "epoch-1", seq: 2 },
startCursor: { epoch: 'epoch-1', seq: 1 },
endCursor: { epoch: 'epoch-1', seq: 2 },
hasOlder: false,
hasNewer: false,
entries: [
{
provider: "codex",
item: { type: "assistant_message", text: "hello" },
timestamp: "2026-02-08T20:10:00.000Z",
provider: 'codex',
item: { type: 'assistant_message', text: 'hello' },
timestamp: '2026-02-08T20:10:00.000Z',
seqStart: 1,
seqEnd: 2,
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
collapsed: ["assistant_merge"],
collapsed: ['assistant_merge'],
},
],
error: null,
},
});
})
expect(parsed.payload.entries).toHaveLength(1);
expect(parsed.payload.entries[0]?.item.type).toBe("assistant_message");
});
expect(parsed.payload.entries).toHaveLength(1)
expect(parsed.payload.entries[0]?.item.type).toBe('assistant_message')
})
it("parses representative agent_stream tool_call event", () => {
it('parses representative agent_stream tool_call event', () => {
const parsed = AgentStreamMessageSchema.parse({
type: "agent_stream",
type: 'agent_stream',
payload: {
agentId: "agent_live",
timestamp: "2026-02-08T20:10:00.000Z",
agentId: 'agent_live',
timestamp: '2026-02-08T20:10:00.000Z',
event: {
type: "timeline",
provider: "claude",
type: 'timeline',
provider: 'claude',
item: {
type: "tool_call",
callId: "call_live",
name: "shell",
status: "running",
type: 'tool_call',
callId: 'call_live',
name: 'shell',
status: 'running',
detail: {
type: "shell",
command: "ls",
type: 'shell',
command: 'ls',
},
error: null,
},
},
},
});
})
expect(parsed.payload.event.type).toBe("timeline");
if (parsed.payload.event.type === "timeline") {
expect(parsed.payload.event.item.type).toBe("tool_call");
if (parsed.payload.event.item.type === "tool_call") {
expect(parsed.payload.event.item.status).toBe("running");
expect(parsed.payload.event.type).toBe('timeline')
if (parsed.payload.event.type === 'timeline') {
expect(parsed.payload.event.item.type).toBe('tool_call')
if (parsed.payload.event.item.type === 'tool_call') {
expect(parsed.payload.event.item.status).toBe('running')
}
}
});
})
it("rejects removed initialize_agent_request inbound payload", () => {
it('rejects removed initialize_agent_request inbound payload', () => {
const parsed = SessionInboundMessageSchema.safeParse({
type: "initialize_agent_request",
agentId: "agent-legacy",
requestId: "req-legacy-1",
});
expect(parsed.success).toBe(false);
});
type: 'initialize_agent_request',
agentId: 'agent-legacy',
requestId: 'req-legacy-1',
})
expect(parsed.success).toBe(false)
})
it("rejects removed initialize_agent_request outbound payload", () => {
it('rejects removed initialize_agent_request outbound payload', () => {
const parsed = SessionOutboundMessageSchema.safeParse({
type: "initialize_agent_request",
type: 'initialize_agent_request',
payload: {
requestId: "req-legacy-1",
agentId: "agent-legacy",
agentStatus: "running",
requestId: 'req-legacy-1',
agentId: 'agent-legacy',
agentStatus: 'running',
timelineSize: 12,
},
});
expect(parsed.success).toBe(false);
});
})
expect(parsed.success).toBe(false)
})
it("parses directory suggestions request and response payloads", () => {
it('parses directory suggestions request and response payloads', () => {
const requestParsed = SessionInboundMessageSchema.safeParse({
type: "directory_suggestions_request",
query: "proj",
type: 'directory_suggestions_request',
query: 'proj',
cwd: '/tmp/project',
includeFiles: true,
includeDirectories: true,
limit: 20,
requestId: "req-dir-1",
});
expect(requestParsed.success).toBe(true);
requestId: 'req-dir-1',
})
expect(requestParsed.success).toBe(true)
const responseParsed = SessionOutboundMessageSchema.safeParse({
type: "directory_suggestions_response",
type: 'directory_suggestions_response',
payload: {
directories: ["/Users/test/projects/paseo"],
directories: ['/Users/test/projects/paseo'],
entries: [{ path: '/Users/test/projects/paseo', kind: 'directory' }],
error: null,
requestId: "req-dir-1",
requestId: 'req-dir-1',
},
});
expect(responseParsed.success).toBe(true);
});
})
expect(responseParsed.success).toBe(true)
})
it("rejects websocket envelope for removed agent_stream_snapshot message type", () => {
it('rejects websocket envelope for removed agent_stream_snapshot message type', () => {
const fixture = {
type: "agent_stream_snapshot",
type: 'agent_stream_snapshot',
payload: {
agentId: "agent-legacy",
agentId: 'agent-legacy',
events: [],
},
};
}
const wrapped = WSOutboundMessageSchema.safeParse({
type: "session",
type: 'session',
message: fixture,
});
expect(wrapped.success).toBe(false);
});
})
expect(wrapped.success).toBe(false)
})
it("rejects removed legacy git diff request messages", () => {
it('rejects removed legacy git diff request messages', () => {
const gitDiffParsed = SessionInboundMessageSchema.safeParse({
type: "git_diff_request",
agentId: "agent-1",
requestId: "req-1",
});
expect(gitDiffParsed.success).toBe(false);
type: 'git_diff_request',
agentId: 'agent-1',
requestId: 'req-1',
})
expect(gitDiffParsed.success).toBe(false)
const highlightedParsed = SessionInboundMessageSchema.safeParse({
type: "highlighted_diff_request",
agentId: "agent-1",
requestId: "req-2",
});
expect(highlightedParsed.success).toBe(false);
});
type: 'highlighted_diff_request',
agentId: 'agent-1',
requestId: 'req-2',
})
expect(highlightedParsed.success).toBe(false)
})
it("rejects removed legacy git diff response messages", () => {
it('rejects removed legacy git diff response messages', () => {
const gitDiffParsed = SessionOutboundMessageSchema.safeParse({
type: "git_diff_response",
type: 'git_diff_response',
payload: {
agentId: "agent-1",
diff: "",
agentId: 'agent-1',
diff: '',
error: null,
requestId: "req-1",
requestId: 'req-1',
},
});
expect(gitDiffParsed.success).toBe(false);
})
expect(gitDiffParsed.success).toBe(false)
const highlightedParsed = SessionOutboundMessageSchema.safeParse({
type: "highlighted_diff_response",
type: 'highlighted_diff_response',
payload: {
agentId: "agent-1",
agentId: 'agent-1',
files: [],
error: null,
requestId: "req-2",
requestId: 'req-2',
},
});
expect(highlightedParsed.success).toBe(false);
});
});
})
expect(highlightedParsed.success).toBe(false)
})
})
File diff suppressed because it is too large Load Diff
@@ -1,165 +1,237 @@
import {
mkdtempSync,
mkdirSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { searchHomeDirectories } from "./directory-suggestions.js";
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { searchHomeDirectories, searchWorkspaceEntries } from './directory-suggestions.js'
describe("searchHomeDirectories", () => {
let tempRoot: string;
let homeDir: string;
let outsideDir: string;
describe('searchHomeDirectories', () => {
let tempRoot: string
let homeDir: string
let outsideDir: string
beforeEach(() => {
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "directory-suggestions-")));
homeDir = path.join(tempRoot, "home");
outsideDir = path.join(tempRoot, "outside");
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), 'directory-suggestions-')))
homeDir = path.join(tempRoot, 'home')
outsideDir = path.join(tempRoot, 'outside')
mkdirSync(homeDir, { recursive: true });
mkdirSync(outsideDir, { recursive: true });
mkdirSync(homeDir, { recursive: true })
mkdirSync(outsideDir, { recursive: true })
mkdirSync(path.join(homeDir, "projects", "paseo"), { recursive: true });
mkdirSync(path.join(homeDir, "projects", "playground"), { recursive: true });
mkdirSync(path.join(homeDir, "documents", "plans"), { recursive: true });
mkdirSync(path.join(homeDir, ".hidden", "cache"), { recursive: true });
writeFileSync(path.join(homeDir, "projects", "README.md"), "not a directory\n");
mkdirSync(path.join(homeDir, 'projects', 'paseo'), { recursive: true })
mkdirSync(path.join(homeDir, 'projects', 'playground'), { recursive: true })
mkdirSync(path.join(homeDir, 'documents', 'plans'), { recursive: true })
mkdirSync(path.join(homeDir, '.hidden', 'cache'), { recursive: true })
writeFileSync(path.join(homeDir, 'projects', 'README.md'), 'not a directory\n')
mkdirSync(path.join(outsideDir, "outside-match"), { recursive: true });
symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link"));
});
mkdirSync(path.join(outsideDir, 'outside-match'), { recursive: true })
symlinkSync(path.join(outsideDir, 'outside-match'), path.join(homeDir, 'outside-link'))
})
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true });
});
rmSync(tempRoot, { recursive: true, force: true })
})
it("returns an empty list for blank queries", async () => {
it('returns an empty list for blank queries', async () => {
await expect(
searchHomeDirectories({
homeDir,
query: " ",
query: ' ',
limit: 10,
})
).resolves.toEqual([]);
});
).resolves.toEqual([])
})
it("returns only existing directories", async () => {
it('returns only existing directories', async () => {
const results = await searchHomeDirectories({
homeDir,
query: "proj",
query: 'proj',
limit: 10,
});
})
expect(results).toContain(path.join(homeDir, "projects"));
expect(results).toContain(path.join(homeDir, "projects", "paseo"));
expect(results).not.toContain(path.join(homeDir, "projects", "README.md"));
});
expect(results).toContain(path.join(homeDir, 'projects'))
expect(results).toContain(path.join(homeDir, 'projects', 'paseo'))
expect(results).not.toContain(path.join(homeDir, 'projects', 'README.md'))
})
it("supports home-relative path query syntax", async () => {
it('supports home-relative path query syntax', async () => {
const results = await searchHomeDirectories({
homeDir,
query: "~/projects/pa",
query: '~/projects/pa',
limit: 10,
});
})
expect(results).toEqual([
path.join(homeDir, "projects", "paseo"),
]);
});
expect(results).toEqual([path.join(homeDir, 'projects', 'paseo')])
})
it("prioritizes exact segment matches before segment-prefix matches", async () => {
const exactSegmentPath = path.join(
homeDir,
"something",
"faro",
"something-else"
);
const prefixSegmentPath = path.join(
homeDir,
"something",
"somethingelse",
"faro-bla"
);
mkdirSync(exactSegmentPath, { recursive: true });
mkdirSync(prefixSegmentPath, { recursive: true });
it('prioritizes exact segment matches before segment-prefix matches', async () => {
const exactSegmentPath = path.join(homeDir, 'something', 'faro', 'something-else')
const prefixSegmentPath = path.join(homeDir, 'something', 'somethingelse', 'faro-bla')
mkdirSync(exactSegmentPath, { recursive: true })
mkdirSync(prefixSegmentPath, { recursive: true })
const results = await searchHomeDirectories({
homeDir,
query: "faro",
query: 'faro',
limit: 30,
});
})
const exactIndex = results.indexOf(exactSegmentPath);
const prefixIndex = results.indexOf(prefixSegmentPath);
expect(exactIndex).toBeGreaterThanOrEqual(0);
expect(prefixIndex).toBeGreaterThanOrEqual(0);
expect(exactIndex).toBeLessThan(prefixIndex);
});
const exactIndex = results.indexOf(exactSegmentPath)
const prefixIndex = results.indexOf(prefixSegmentPath)
expect(exactIndex).toBeGreaterThanOrEqual(0)
expect(prefixIndex).toBeGreaterThanOrEqual(0)
expect(exactIndex).toBeLessThan(prefixIndex)
})
it("prioritizes partial matches that appear earlier in the path", async () => {
const earlierPath = path.join(homeDir, "farofoo");
const laterPath = path.join(homeDir, "x", "y", "farofoo");
mkdirSync(earlierPath, { recursive: true });
mkdirSync(laterPath, { recursive: true });
it('prioritizes partial matches that appear earlier in the path', async () => {
const earlierPath = path.join(homeDir, 'farofoo')
const laterPath = path.join(homeDir, 'x', 'y', 'farofoo')
mkdirSync(earlierPath, { recursive: true })
mkdirSync(laterPath, { recursive: true })
const results = await searchHomeDirectories({
homeDir,
query: "arofo",
query: 'arofo',
limit: 30,
});
})
const earlierIndex = results.indexOf(earlierPath);
const laterIndex = results.indexOf(laterPath);
expect(earlierIndex).toBeGreaterThanOrEqual(0);
expect(laterIndex).toBeGreaterThanOrEqual(0);
expect(earlierIndex).toBeLessThan(laterIndex);
});
const earlierIndex = results.indexOf(earlierPath)
const laterIndex = results.indexOf(laterPath)
expect(earlierIndex).toBeGreaterThanOrEqual(0)
expect(laterIndex).toBeGreaterThanOrEqual(0)
expect(earlierIndex).toBeLessThan(laterIndex)
})
it("returns home-root suggestions when query is '~'", async () => {
const results = await searchHomeDirectories({
homeDir,
query: "~",
query: '~',
limit: 20,
});
})
expect(results).toContain(path.join(homeDir, "projects"));
expect(results).toContain(path.join(homeDir, "documents"));
expect(results).not.toContain(path.join(homeDir, ".hidden"));
});
expect(results).toContain(path.join(homeDir, 'projects'))
expect(results).toContain(path.join(homeDir, 'documents'))
expect(results).not.toContain(path.join(homeDir, '.hidden'))
})
it("does not return hidden directories during tree search", async () => {
it('does not return hidden directories during tree search', async () => {
const results = await searchHomeDirectories({
homeDir,
query: "cache",
query: 'cache',
limit: 20,
});
})
expect(results).not.toContain(path.join(homeDir, ".hidden", "cache"));
});
expect(results).not.toContain(path.join(homeDir, '.hidden', 'cache'))
})
it("does not return paths that escape home through symlinks", async () => {
it('does not return paths that escape home through symlinks', async () => {
const results = await searchHomeDirectories({
homeDir,
query: "outside",
query: 'outside',
limit: 20,
});
})
expect(results).not.toContain(path.join(homeDir, "outside-link"));
expect(results).not.toContain(path.join(outsideDir, "outside-match"));
});
expect(results).not.toContain(path.join(homeDir, 'outside-link'))
expect(results).not.toContain(path.join(outsideDir, 'outside-match'))
})
it("respects the result limit", async () => {
it('respects the result limit', async () => {
const results = await searchHomeDirectories({
homeDir,
query: "p",
query: 'p',
limit: 1,
});
})
expect(results).toHaveLength(1);
});
});
expect(results).toHaveLength(1)
})
})
describe('searchWorkspaceEntries', () => {
let tempRoot: string
let workspaceDir: string
let outsideDir: string
beforeEach(() => {
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), 'workspace-suggestions-')))
workspaceDir = path.join(tempRoot, 'workspace')
outsideDir = path.join(tempRoot, 'outside')
mkdirSync(path.join(workspaceDir, 'src', 'components'), {
recursive: true,
})
mkdirSync(path.join(workspaceDir, 'docs'), { recursive: true })
mkdirSync(path.join(outsideDir, 'escaped'), { recursive: true })
writeFileSync(path.join(workspaceDir, 'README.md'), '# paseo\n')
writeFileSync(
path.join(workspaceDir, 'src', 'components', 'chat-input.tsx'),
'export const ChatInput = null;\n'
)
writeFileSync(path.join(workspaceDir, 'docs', 'notes.md'), 'notes\n')
symlinkSync(path.join(outsideDir, 'escaped'), path.join(workspaceDir, 'escaped-link'))
})
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true })
})
it('returns relative file and directory suggestions for workspace queries', async () => {
const results = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'chat',
limit: 20,
includeFiles: true,
includeDirectories: true,
})
expect(results).toContainEqual({
path: 'src/components/chat-input.tsx',
kind: 'file',
})
expect(results.some((entry) => entry.path === path.join(workspaceDir, 'src'))).toBe(false)
})
it('filters entries by kind', async () => {
const dirsOnly = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'src',
limit: 20,
includeFiles: false,
includeDirectories: true,
})
expect(dirsOnly.some((entry) => entry.kind === 'file')).toBe(false)
expect(dirsOnly.some((entry) => entry.path === 'src')).toBe(true)
const filesOnly = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'readme',
limit: 20,
includeFiles: true,
includeDirectories: false,
})
expect(filesOnly).toEqual([{ path: 'README.md', kind: 'file' }])
})
it('supports path-style queries and does not escape cwd through symlinks', async () => {
const pathResults = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'src/co',
limit: 20,
includeFiles: true,
includeDirectories: true,
})
expect(pathResults).toContainEqual({
path: 'src/components',
kind: 'directory',
})
const escapedResults = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'escaped',
limit: 20,
includeFiles: true,
includeDirectories: true,
})
expect(escapedResults.some((entry) => entry.path.includes('escaped-link'))).toBe(false)
})
})
File diff suppressed because it is too large Load Diff