Add command autocomplete and stream spacing updates

This commit is contained in:
Mohamed Boudra
2026-01-11 22:36:34 +07:00
parent 0ec2fc62db
commit f81fd1c01a
25 changed files with 949 additions and 93 deletions
@@ -0,0 +1,62 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../../test-utils/index.js";
import type { AgentSlashCommand } from "../agent-sdk-types.js";
describe("claude agent commands E2E", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
test("lists available slash commands for a claude agent", async () => {
// Create a Claude agent
const agent = await ctx.client.createAgent({
provider: "claude",
cwd: "/tmp",
title: "Commands Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.provider).toBe("claude");
expect(agent.status).toBe("idle");
// List commands
const result = await ctx.client.listCommands(agent.id);
// Should have no error
expect(result.error).toBeNull();
// Should have commands
expect(result.commands.length).toBeGreaterThan(0);
// Each command should have required fields
for (const cmd of result.commands) {
expect(cmd.name).toBeTruthy();
expect(typeof cmd.description).toBe("string");
expect(typeof cmd.argumentHint).toBe("string");
}
// Should include some well-known commands
const commandNames = result.commands.map((c) => c.name);
// These are skills that come from CLAUDE.md configurations
// At minimum we should have some commands available
console.log("Available commands:", commandNames);
expect(commandNames.length).toBeGreaterThan(0);
}, 120000);
test("returns error for non-existent agent", async () => {
const result = await ctx.client.listCommands("non-existent-agent-id");
expect(result.error).toBeTruthy();
expect(result.error).toContain("Agent not found");
expect(result.commands).toEqual([]);
}, 30000);
});
+26
View File
@@ -523,6 +523,12 @@ export const ClearAgentAttentionMessageSchema = z.object({
agentId: z.union([z.string(), z.array(z.string())]),
});
export const ListCommandsRequestSchema = z.object({
type: z.literal("list_commands_request"),
agentId: z.string(),
requestId: z.string().optional(),
});
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
UserTextMessageSchema,
RealtimeAudioChunkMessageSchema,
@@ -550,6 +556,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
FileDownloadTokenRequestSchema,
GitRepoInfoRequestMessageSchema,
ClearAgentAttentionMessageSchema,
ListCommandsRequestSchema,
]);
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
@@ -805,6 +812,22 @@ export const ListProviderModelsResponseMessageSchema = z.object({
}),
});
const AgentSlashCommandSchema = z.object({
name: z.string(),
description: z.string(),
argumentHint: z.string(),
});
export const ListCommandsResponseSchema = z.object({
type: z.literal("list_commands_response"),
payload: z.object({
agentId: z.string(),
commands: z.array(AgentSlashCommandSchema),
error: z.string().nullable(),
requestId: z.string().optional(),
}),
});
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ActivityLogMessageSchema,
AssistantChunkMessageSchema,
@@ -830,6 +853,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FileDownloadTokenResponseSchema,
GitRepoInfoResponseSchema,
ListProviderModelsResponseMessageSchema,
ListCommandsResponseSchema,
]);
export type SessionOutboundMessage = z.infer<
@@ -888,6 +912,8 @@ export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSc
export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>;
export type RestartServerRequestMessage = z.infer<typeof RestartServerRequestMessageSchema>;
export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessageSchema>;
export type ListCommandsRequest = z.infer<typeof ListCommandsRequestSchema>;
export type ListCommandsResponse = z.infer<typeof ListCommandsResponseSchema>;
// ============================================================================
// WebSocket Level Messages (wraps session messages)
+71
View File
@@ -794,6 +794,10 @@ export class Session {
case "clear_agent_attention":
await this.handleClearAgentAttention(msg.agentId);
break;
case "list_commands_request":
await this.handleListCommandsRequest(msg.agentId, msg.requestId);
break;
}
} catch (error: any) {
console.error(
@@ -1818,6 +1822,73 @@ export class Session {
}
}
/**
* Handle list commands request for an agent
*/
private async handleListCommandsRequest(agentId: string, requestId?: string): Promise<void> {
console.log(
`[Session ${this.clientId}] Handling list commands request for agent ${agentId}`
);
try {
const agents = this.agentManager.listAgents();
const agent = agents.find((a) => a.id === agentId);
if (!agent) {
this.emit({
type: "list_commands_response",
payload: {
agentId,
commands: [],
error: `Agent not found: ${agentId}`,
requestId,
},
});
return;
}
const session = agent.session;
if (!session || !session.listCommands) {
this.emit({
type: "list_commands_response",
payload: {
agentId,
commands: [],
error: `Agent does not support listing commands`,
requestId,
},
});
return;
}
const commands = await session.listCommands();
this.emit({
type: "list_commands_response",
payload: {
agentId,
commands,
error: null,
requestId,
},
});
} catch (error: any) {
console.error(
`[Session ${this.clientId}] Failed to list commands:`,
error
);
this.emit({
type: "list_commands_response",
payload: {
agentId,
commands: [],
error: error.message,
requestId,
},
});
}
}
/**
* Handle agent permission response from user
*/
@@ -12,6 +12,7 @@ import type {
AgentPermissionResponse,
AgentPersistenceHandle,
AgentProvider,
AgentSlashCommand,
} from "../agent/agent-sdk-types.js";
import { getAgentProviderDefinition } from "../agent/provider-registry.js";
@@ -616,6 +617,41 @@ export class DaemonClient {
);
}
// ============================================================================
// Commands
// ============================================================================
async listCommands(agentId: string): Promise<{
agentId: string;
commands: AgentSlashCommand[];
error: string | null;
}> {
const startPosition = this.messageQueue.length;
this.send({
type: "list_commands_request",
agentId,
});
return this.waitFor(
(msg) => {
if (
msg.type === "list_commands_response" &&
msg.payload.agentId === agentId
) {
return {
agentId: msg.payload.agentId,
commands: msg.payload.commands ?? [],
error: msg.payload.error ?? null,
};
}
return null;
},
30000,
{ skipQueueBefore: startPosition }
);
}
// ============================================================================
// Permissions
// ============================================================================