5ba5b9899d
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
309 lines
8.7 KiB
TypeScript
309 lines
8.7 KiB
TypeScript
/**
|
|
* Integration tests for the goosed binary using the TypeScript API client.
|
|
*
|
|
* These tests spawn a real goosed process and issue requests via the
|
|
* auto-generated API client to verify the server is working correctly.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { setupGoosed, type GoosedTestContext } from './setup';
|
|
import {
|
|
status,
|
|
readConfig,
|
|
providers,
|
|
startAgent,
|
|
stopAgent,
|
|
listSessions,
|
|
getSession,
|
|
updateAgentProvider,
|
|
reply,
|
|
} from '../../src/api';
|
|
import { execSync } from 'child_process';
|
|
import os from 'node:os';
|
|
|
|
const CONSTRAINED_PATH = '/usr/bin:/bin:/usr/sbin:/sbin';
|
|
|
|
function getUserPath(): string[] {
|
|
try {
|
|
const userShell = process.env.SHELL || '/bin/bash';
|
|
const path = execSync(`${userShell} -l -i -c 'echo $PATH'`, {
|
|
encoding: 'utf-8',
|
|
timeout: 5000,
|
|
env: {
|
|
PATH: CONSTRAINED_PATH,
|
|
},
|
|
}).trim();
|
|
|
|
const delimiter = process.platform === 'win32' ? ';' : ':';
|
|
return path.split(delimiter).filter((entry: string) => entry.length > 0);
|
|
} catch (error) {
|
|
console.error('Error executing shell:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
describe('goosed API integration tests', () => {
|
|
let ctx: GoosedTestContext;
|
|
|
|
beforeAll(async () => {
|
|
const configYaml = `
|
|
extensions:
|
|
developer:
|
|
enabled: true
|
|
type: builtin
|
|
name: developer
|
|
description: General development tools useful for software engineering.
|
|
display_name: Developer
|
|
timeout: 300
|
|
bundled: true
|
|
available_tools: []
|
|
`;
|
|
|
|
ctx = await setupGoosed({ pathOverride: '/usr/bin:/bin', configYaml });
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await ctx.cleanup();
|
|
});
|
|
|
|
describe('health', () => {
|
|
it('should respond to status endpoint', async () => {
|
|
const response = await status({ client: ctx.client });
|
|
expect(response.response).toBeOkResponse();
|
|
expect(response.data).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('configuration', () => {
|
|
it('should read config value (or return null for missing key)', async () => {
|
|
const response = await readConfig({
|
|
client: ctx.client,
|
|
body: {
|
|
key: 'GOOSE_PROVIDER',
|
|
is_secret: false,
|
|
},
|
|
});
|
|
expect(response.response).toBeOkResponse();
|
|
});
|
|
});
|
|
|
|
describe('providers', () => {
|
|
it('should list available providers', async () => {
|
|
const response = await providers({ client: ctx.client });
|
|
expect(response.response).toBeOkResponse();
|
|
expect(response.data).toBeDefined();
|
|
expect(Array.isArray(response.data)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('sessions', () => {
|
|
it('should start an agent and create a session', async () => {
|
|
const startResponse = await startAgent({
|
|
client: ctx.client,
|
|
body: {
|
|
working_dir: os.tmpdir(),
|
|
},
|
|
});
|
|
expect(startResponse.response).toBeOkResponse();
|
|
expect(startResponse.data).toBeDefined();
|
|
|
|
const session = startResponse.data!;
|
|
expect(session.id).toBeDefined();
|
|
expect(session.name).toBeDefined();
|
|
|
|
const getResponse = await getSession({
|
|
client: ctx.client,
|
|
path: {
|
|
session_id: session.id,
|
|
},
|
|
});
|
|
expect(getResponse.response).toBeOkResponse();
|
|
expect(getResponse.data).toBeDefined();
|
|
expect(getResponse.data!.id).toBe(session.id);
|
|
});
|
|
|
|
it('should list sessions', async () => {
|
|
const sessionsResponse = await listSessions({ client: ctx.client });
|
|
expect(sessionsResponse.response).toBeOkResponse();
|
|
expect(sessionsResponse.data).toBeDefined();
|
|
expect(sessionsResponse.data!.sessions).toBeDefined();
|
|
expect(Array.isArray(sessionsResponse.data!.sessions)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('messaging', () => {
|
|
it('should accept a message request to /reply endpoint', async () => {
|
|
// Start a session first
|
|
const startResponse = await startAgent({
|
|
client: ctx.client,
|
|
body: {
|
|
working_dir: os.tmpdir(),
|
|
},
|
|
});
|
|
expect(startResponse.response).toBeOkResponse();
|
|
const sessionId = startResponse.data!.id;
|
|
|
|
const abortController = new AbortController();
|
|
const { stream } = await reply({
|
|
client: ctx.client,
|
|
body: {
|
|
session_id: sessionId,
|
|
user_message: {
|
|
role: 'user',
|
|
created: Math.floor(Date.now() / 1000),
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'Hello',
|
|
},
|
|
],
|
|
metadata: {
|
|
userVisible: true,
|
|
agentVisible: true,
|
|
},
|
|
},
|
|
},
|
|
throwOnError: true,
|
|
signal: abortController.signal,
|
|
});
|
|
|
|
const timeout = setTimeout(() => abortController.abort(), 1000);
|
|
try {
|
|
for await (const event of stream) {
|
|
expect(event).toBeDefined();
|
|
break;
|
|
}
|
|
} catch {
|
|
// Aborted or error, that's fine
|
|
}
|
|
clearTimeout(timeout);
|
|
|
|
await stopAgent({
|
|
client: ctx.client,
|
|
body: {
|
|
session_id: sessionId,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('the developer tool', () => {
|
|
it('should see the full PATH when calling the developer tool', async (testContext) => {
|
|
const currentPath = getUserPath();
|
|
|
|
const pathEntry = currentPath.find((entry) => !CONSTRAINED_PATH.includes(entry));
|
|
if (!pathEntry) {
|
|
expect.fail(`Could not find a path entry not in ${CONSTRAINED_PATH}`);
|
|
}
|
|
|
|
let configResponse = await readConfig({
|
|
client: ctx.client,
|
|
body: {
|
|
key: 'GOOSE_PROVIDER',
|
|
is_secret: false,
|
|
},
|
|
});
|
|
|
|
let providerName = configResponse.data as string | null | undefined;
|
|
|
|
if (!providerName) {
|
|
testContext.skip('Skipping tool execution test - no GOOSE_PROVIDER configured');
|
|
return;
|
|
}
|
|
|
|
const modelResponse = await readConfig({
|
|
client: ctx.client,
|
|
body: {
|
|
key: 'GOOSE_MODEL',
|
|
is_secret: false,
|
|
},
|
|
});
|
|
const modelName = (modelResponse.data as string | null) || undefined;
|
|
|
|
const startResponse = await startAgent({
|
|
client: ctx.client,
|
|
body: {
|
|
working_dir: os.tmpdir(),
|
|
},
|
|
});
|
|
expect(startResponse.response).toBeOkResponse();
|
|
const sessionId = startResponse.data!.id;
|
|
|
|
const providerResponse = await updateAgentProvider({
|
|
client: ctx.client,
|
|
body: {
|
|
session_id: sessionId,
|
|
provider: providerName,
|
|
model: modelName,
|
|
},
|
|
});
|
|
expect(providerResponse.response).toBeOkResponse();
|
|
|
|
const abortController = new AbortController();
|
|
const { stream } = await reply({
|
|
client: ctx.client,
|
|
body: {
|
|
session_id: sessionId,
|
|
user_message: {
|
|
role: 'user',
|
|
created: Math.floor(Date.now() / 1000),
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'Use your developer shell tool to read $PATH and return its content directly, with no further information about it',
|
|
},
|
|
],
|
|
metadata: {
|
|
userVisible: true,
|
|
agentVisible: true,
|
|
},
|
|
},
|
|
},
|
|
throwOnError: true,
|
|
signal: abortController.signal,
|
|
});
|
|
|
|
let returnedPath: string | undefined = undefined;
|
|
const timeout = setTimeout(() => abortController.abort(), 60000); // 60s timeout
|
|
|
|
try {
|
|
for await (const event of stream) {
|
|
console.log('stream: ', JSON.stringify(event));
|
|
|
|
if (event.type === 'Message') {
|
|
const content = event.message?.content?.[0];
|
|
if (content?.type === 'toolResponse') {
|
|
const toolResult = content as {
|
|
toolResult?: { value?: { content?: Array<{ text?: string }> } };
|
|
};
|
|
const output = toolResult?.toolResult?.value?.content?.[0]?.text;
|
|
if (output && output.includes('/usr')) {
|
|
clearTimeout(timeout);
|
|
abortController.abort();
|
|
returnedPath = output;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Aborted or error
|
|
if (!(error instanceof Error && error.name === 'AbortError')) {
|
|
console.log('Stream error: ', error);
|
|
}
|
|
}
|
|
clearTimeout(timeout);
|
|
|
|
await stopAgent({
|
|
client: ctx.client,
|
|
body: {
|
|
session_id: sessionId,
|
|
},
|
|
});
|
|
|
|
expect(returnedPath, 'the agent should return a value for $PATH').toBeDefined();
|
|
expect(returnedPath, '$PATH should contain the expected entry').toContain(pathEntry);
|
|
});
|
|
});
|
|
});
|