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
1 change: 1 addition & 0 deletions packages/happy-app/sources/-session/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session:
// Function to update permission mode
const updatePermissionMode = React.useCallback((mode: PermissionMode) => {
storage.getState().updateSessionPermissionMode(sessionId, mode.key);
sync.applySettings({ lastUsedPermissionMode: mode.key });
}, [sessionId]);

const updateModelMode = React.useCallback((mode: ModelMode) => {
Expand Down
30 changes: 30 additions & 0 deletions packages/happy-app/sources/components/modelModeOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,34 @@ describe('modelModeOptions', () => {
expect(resolveCurrentOption(options, ['missing', 'b', 'a'])).toEqual({ key: 'b', name: 'B' });
expect(resolveCurrentOption(options, ['missing'])).toBeNull();
});

it('resolves lastUsedPermissionMode for new sessions (regression #648)', () => {
const modes = getClaudePermissionModes(translate);
const defaultKey = 'default';
const bypassKey = 'bypassPermissions';

// Simulate: user previously set YOLO (bypassPermissions), saved as lastUsedPermissionMode
const lastUsedPermissionMode = bypassKey;

const resolved = resolveCurrentOption(modes, [
lastUsedPermissionMode,
defaultKey,
]);

expect(resolved).toBeTruthy();
expect(resolved!.key).toBe(bypassKey);
});

it('falls back to default when lastUsedPermissionMode is null', () => {
const modes = getClaudePermissionModes(translate);
const lastUsedPermissionMode: string | null = null;

const resolved = resolveCurrentOption(modes, [
lastUsedPermissionMode,
'default',
].filter((k): k is string => k !== null));

expect(resolved).toBeTruthy();
expect(resolved!.key).toBe('default');
});
});
94 changes: 94 additions & 0 deletions packages/happy-cli/src/claude/utils/startHappyServer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Tests for Happy MCP server
* Verifies that the per-request transport pattern works correctly,
* especially with @modelcontextprotocol/sdk >= 1.26.0 which forbids
* reusing a stateless StreamableHTTPServerTransport across requests.
*/

import { describe, it, expect, afterEach } from 'vitest'
import { startHappyServer } from './startHappyServer'
import http from 'node:http'

function createMockClient() {
const messages: unknown[] = [];
return {
sessionId: 'test-session-123',
sendClaudeSessionMessage: (msg: unknown) => {
messages.push(msg);
},
_messages: messages,
} as any;
}

function postJSON(url: string, body: object): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const data = JSON.stringify(body);
const req = http.request(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
},
}, (res) => {
let body = '';
res.on('data', (chunk) => body += chunk);
res.on('end', () => resolve({ status: res.statusCode!, body }));
});
req.on('error', reject);
req.write(data);
req.end();
});
}

const MCP_INITIALIZE = {
jsonrpc: '2.0',
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'test', version: '1.0.0' },
},
id: 1,
};

describe('startHappyServer', () => {
let server: Awaited<ReturnType<typeof startHappyServer>> | null = null;

afterEach(() => {
server?.stop();
server = null;
});

it('should start and return a url and tool names', async () => {
const client = createMockClient();
server = await startHappyServer(client);

expect(server.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+/);
expect(server.toolNames).toEqual(['change_title']);
});

it('should respond to a single MCP initialize request', async () => {
const client = createMockClient();
server = await startHappyServer(client);

const res = await postJSON(server.url, MCP_INITIALIZE);
expect(res.status).toBe(200);
});

it('should handle multiple sequential requests without 500 errors', async () => {
const client = createMockClient();
server = await startHappyServer(client);

// This is the core regression test: SDK >= 1.26.0 throws
// "Stateless transport cannot be reused across requests"
// if a single transport handles more than one request.
const res1 = await postJSON(server.url, MCP_INITIALIZE);
expect(res1.status).toBe(200);

const res2 = await postJSON(server.url, MCP_INITIALIZE);
expect(res2.status).toBe(200);

const res3 = await postJSON(server.url, MCP_INITIALIZE);
expect(res3.status).toBe(200);
});
});
97 changes: 53 additions & 44 deletions packages/happy-cli/src/claude/utils/startHappyServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,59 +33,65 @@ export async function startHappyServer(client: ApiSessionClient) {
};

//
// Create the MCP server
// Create a per-request MCP server factory
// @modelcontextprotocol/sdk >= 1.26.0 forbids reusing a stateless
// StreamableHTTPServerTransport across requests, so we create a fresh
// McpServer + transport for each incoming request (following the SDK's
// own simpleStatelessStreamableHttp.ts example).
//

const mcp = new McpServer({
name: "Happy MCP",
version: "1.0.0",
});
function createMcpServer(): McpServer {
const mcp = new McpServer({
name: "Happy MCP",
version: "1.0.0",
});

mcp.registerTool('change_title', {
description: 'Change the title of the current chat session',
title: 'Change Chat Title',
inputSchema: {
title: z.string().describe('The new title for the chat session'),
},
}, async (args) => {
const response = await handler(args.title);
logger.debug('[happyMCP] Response:', response);

if (response.success) {
return {
content: [
{
type: 'text',
text: `Successfully changed chat title to: "${args.title}"`,
},
],
isError: false,
};
} else {
return {
content: [
{
type: 'text',
text: `Failed to change chat title: ${response.error || 'Unknown error'}`,
},
],
isError: true,
};
}
});
mcp.registerTool('change_title', {
description: 'Change the title of the current chat session',
title: 'Change Chat Title',
inputSchema: {
title: z.string().describe('The new title for the chat session'),
},
}, async (args) => {
const response = await handler(args.title);
logger.debug('[happyMCP] Response:', response);

const transport = new StreamableHTTPServerTransport({
// NOTE: Returning session id here will result in claude
// sdk spawn to fail with `Invalid Request: Server already initialized`
sessionIdGenerator: undefined
});
await mcp.connect(transport);
if (response.success) {
return {
content: [
{
type: 'text',
text: `Successfully changed chat title to: "${args.title}"`,
},
],
isError: false,
};
} else {
return {
content: [
{
type: 'text',
text: `Failed to change chat title: ${response.error || 'Unknown error'}`,
},
],
isError: true,
};
}
});

return mcp;
}

//
// Create the HTTP server
//

const server = createServer(async (req, res) => {
const mcp = createMcpServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
await mcp.connect(transport);
try {
await transport.handleRequest(req, res);
} catch (error) {
Expand All @@ -94,6 +100,10 @@ export async function startHappyServer(client: ApiSessionClient) {
res.writeHead(500).end();
}
}
res.on("close", () => {
transport.close();
mcp.close();
});
});

const baseUrl = await new Promise<URL>((resolve) => {
Expand All @@ -110,7 +120,6 @@ export async function startHappyServer(client: ApiSessionClient) {
toolNames: ['change_title'],
stop: () => {
logger.debug(`[happyMCP] server:stop sessionId=${client.sessionId}`);
mcp.close();
server.close();
}
}
Expand Down