diff --git a/packages/main/src/plugin/agent-workspace/agent-workspace-manager.spec.ts b/packages/main/src/plugin/agent-workspace/agent-workspace-manager.spec.ts index 3d668975c..6be4c02a8 100644 --- a/packages/main/src/plugin/agent-workspace/agent-workspace-manager.spec.ts +++ b/packages/main/src/plugin/agent-workspace/agent-workspace-manager.spec.ts @@ -89,6 +89,7 @@ const openshellCli = new OpenshellCli({} as Exec, {} as CliToolRegistry); const agentRegistry = { getAgentRegistration: vi.fn(), + getAgent: vi.fn(), } as unknown as AgentRegistry; const mockTask = { @@ -1206,6 +1207,149 @@ describe('dispose', () => { }); }); +describe('terminal agent command execution', () => { + const SANDBOXES_WITH_AGENT: GatewaySandboxes[] = [ + { + gateway: { name: 'kaiden', endpoint: 'http://localhost:10080' }, + sandboxes: [ + { + id: 'ws-agent', + name: 'agent-workspace', + phase: 'Ready', + labels: { [AGENT_LABEL]: 'test-agent' }, + }, + { + id: 'ws-no-label', + name: 'no-label-workspace', + phase: 'Ready', + }, + ], + }, + ]; + + function createTerminalMockPty(): { pty: IPty; triggerData: (data: string) => void } { + let onDataCb: ((data: string) => void) | undefined; + const pty = { + onData: vi.fn((cb: (data: string) => void) => { + onDataCb = cb; + return { dispose: vi.fn() }; + }), + onExit: vi.fn(() => ({ dispose: vi.fn() })), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + pid: 456, + } as unknown as IPty; + return { + pty, + triggerData: (data: string): void => { + onDataCb?.(data); + }, + }; + } + + function getTerminalHandler(): (_listener: unknown, id: string, onDataId: number) => Promise { + return vi.mocked(ipcHandle).mock.calls.find(call => call[0] === 'agent-workspace:terminal')![1] as ( + _listener: unknown, + id: string, + onDataId: number, + ) => Promise; + } + + test('executes agent command on first terminal data', async () => { + vi.mocked(openshellCli.listSandboxesPerGateway).mockResolvedValue(SANDBOXES_WITH_AGENT); + vi.mocked(agentRegistry.getAgent).mockResolvedValue({ + id: 'test-agent', + name: 'Test Agent', + description: '', + command: '/usr/bin/agent start', + destinationSkillsFolder: '~/.agent', + }); + const { pty, triggerData } = createTerminalMockPty(); + vi.mocked(spawn).mockReturnValue(pty); + + await getTerminalHandler()({}, 'ws-agent', 10); + triggerData('$ '); + + expect(pty.write).toHaveBeenCalledWith('/usr/bin/agent start\n'); + }); + + test('does not execute agent command on subsequent connections', async () => { + vi.mocked(openshellCli.listSandboxesPerGateway).mockResolvedValue(SANDBOXES_WITH_AGENT); + vi.mocked(agentRegistry.getAgent).mockResolvedValue({ + id: 'test-agent', + name: 'Test Agent', + description: '', + command: '/usr/bin/agent start', + destinationSkillsFolder: '~/.agent', + }); + const { pty: pty1, triggerData: triggerData1 } = createTerminalMockPty(); + vi.mocked(spawn).mockReturnValue(pty1); + + await getTerminalHandler()({}, 'ws-agent', 10); + triggerData1('$ '); + + const { pty: pty2, triggerData: triggerData2 } = createTerminalMockPty(); + vi.mocked(spawn).mockReturnValue(pty2); + + await getTerminalHandler()({}, 'ws-agent', 11); + triggerData2('$ '); + + expect(pty2.write).not.toHaveBeenCalled(); + }); + + test('does not execute command when workspace has no agent label', async () => { + vi.mocked(openshellCli.listSandboxesPerGateway).mockResolvedValue(SANDBOXES_WITH_AGENT); + const { pty, triggerData } = createTerminalMockPty(); + vi.mocked(spawn).mockReturnValue(pty); + + await getTerminalHandler()({}, 'ws-no-label', 10); + triggerData('$ '); + + expect(pty.write).not.toHaveBeenCalled(); + expect(agentRegistry.getAgent).not.toHaveBeenCalled(); + }); + + test('does not execute command when agent has no command', async () => { + vi.mocked(openshellCli.listSandboxesPerGateway).mockResolvedValue(SANDBOXES_WITH_AGENT); + vi.mocked(agentRegistry.getAgent).mockResolvedValue({ + id: 'test-agent', + name: 'Test Agent', + description: '', + command: '', + destinationSkillsFolder: '~/.agent', + }); + const { pty, triggerData } = createTerminalMockPty(); + vi.mocked(spawn).mockReturnValue(pty); + + await getTerminalHandler()({}, 'ws-agent', 10); + triggerData('$ '); + + expect(pty.write).not.toHaveBeenCalled(); + }); + + test('executes agent command only once despite multiple data events', async () => { + vi.mocked(openshellCli.listSandboxesPerGateway).mockResolvedValue(SANDBOXES_WITH_AGENT); + vi.mocked(agentRegistry.getAgent).mockResolvedValue({ + id: 'test-agent', + name: 'Test Agent', + description: '', + command: '/usr/bin/agent start', + destinationSkillsFolder: '~/.agent', + }); + const { pty, triggerData } = createTerminalMockPty(); + vi.mocked(spawn).mockReturnValue(pty); + + await getTerminalHandler()({}, 'ws-agent', 10); + triggerData('$ '); + triggerData('more output'); + triggerData('even more output'); + + expect(pty.write).toHaveBeenCalledTimes(1); + expect(pty.write).toHaveBeenCalledWith('/usr/bin/agent start\n'); + }); +}); + describe('encodeWorkspaceLabels', () => { test('returns single label for short paths', () => { const labels = encodeWorkspaceLabels('/tmp/my-project'); diff --git a/packages/main/src/plugin/agent-workspace/agent-workspace-manager.ts b/packages/main/src/plugin/agent-workspace/agent-workspace-manager.ts index 6137beb8a..f6c567423 100644 --- a/packages/main/src/plugin/agent-workspace/agent-workspace-manager.ts +++ b/packages/main/src/plugin/agent-workspace/agent-workspace-manager.ts @@ -80,6 +80,7 @@ export class AgentWorkspaceManager implements Disposable { { write: (param: string) => void; resize: (w: number, h: number) => void } >(); private readonly terminalProcesses = new Map(); + private readonly commandExecutedWorkspaces = new Set(); constructor( @inject(ApiSenderType) @@ -398,6 +399,7 @@ export class AgentWorkspaceManager implements Disposable { task.status = 'in-progress'; try { await this.openshellCli.deleteSandbox(workspaceName); + this.commandExecutedWorkspaces.delete(id); this.apiSender.send('agent-workspace-update'); task.status = 'success'; return { id }; @@ -603,12 +605,36 @@ export class AgentWorkspaceManager implements Disposable { if (!workspace) { throw new Error(`workspace "${id}" not found. Use "workspace list" to see available workspaces.`); } + + const shouldExecuteCommand = !this.commandExecutedWorkspaces.has(id); + let agentCommand: string | undefined; + if (shouldExecuteCommand && workspace.labels) { + const agentId = workspace.labels[AGENT_LABEL]; + if (agentId) { + try { + const agent = await this.agentRegistry.getAgent(agentId); + agentCommand = agent?.command; + } catch (err: unknown) { + console.error(`Failed to resolve agent command for workspace "${id}":`, err); + } + } + } + + if (agentCommand) { + this.commandExecutedWorkspaces.add(id); + } + + let commandSent = false; const invocation = this.shellInAgentWorkspace( workspace.name, (content: string) => { if (!this.webContents.isDestroyed()) { this.webContents.send('agent-workspace:terminal-onData', onDataId, content); } + if (!commandSent && agentCommand) { + commandSent = true; + invocation.write(`${agentCommand}\n`); + } }, (error: string) => { if (!this.webContents.isDestroyed()) { @@ -689,5 +715,6 @@ export class AgentWorkspaceManager implements Disposable { } this.terminalProcesses.clear(); this.terminalCallbacks.clear(); + this.commandExecutedWorkspaces.clear(); } }