Skip to content
Merged
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
305 changes: 237 additions & 68 deletions bun.lock

Large diffs are not rendered by default.

19 changes: 10 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@
"postinstall": "node scripts/ensure-node-pty-helper-permissions.mjs",
"predev": "bun install --frozen-lockfile",
"dev": "env -i PATH=\"$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin\" HOME=\"$HOME\" AUTOHAND_VERSION_SOURCE=git AUTOHAND_DEBUG=\"$AUTOHAND_DEBUG\" ${AUTOHAND_HOME:+AUTOHAND_HOME=\"$AUTOHAND_HOME\"} ${AUTOHAND_CONFIG:+AUTOHAND_CONFIG=\"$AUTOHAND_CONFIG\"} ${AUTOHAND_API_URL:+AUTOHAND_API_URL=\"$AUTOHAND_API_URL\"} ${AUTOHAND_AUTH_URL:+AUTOHAND_AUTH_URL=\"$AUTOHAND_AUTH_URL\"} ${AUTOHAND_DISABLE_STATEFUL_READ:+AUTOHAND_DISABLE_STATEFUL_READ=\"$AUTOHAND_DISABLE_STATEFUL_READ\"} bun src/index.ts",
"typecheck": "tsc --noEmit",
"typecheck": "node ./node_modules/@typescript/native/bin/tsc --noEmit",
"lint": "eslint .",
"proof": "bun run proof:unit && bun run proof:build-tuistory",
"proof:unit": "eslint . && tsc --noEmit && node --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run",
"proof:unit": "eslint . && bun run typecheck && node --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run",
"test": "node --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run",
"test:ci": "node --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run --pool=threads --exclude 'tests/tuistory/**/*.tuistory.test.ts'",
"test:tuistory": "node --max-old-space-size=4096 ./node_modules/vitest/vitest.mjs run --config vitest.tuistory.config.ts",
Expand Down Expand Up @@ -72,22 +72,22 @@
"nodeLlamaCppPostinstall": "skip"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.19.1",
"@aws-sdk/client-bedrock": "^3.1086.0",
"@agentclientprotocol/sdk": "1.3.0",
"@aws-sdk/client-bedrock": "^3.1097.0",
"@aws-sdk/client-bedrock-runtime": "^3.1086.0",
"@aws-sdk/credential-providers": "^3.1090.0",
"@ff-labs/fff-bun": "0.9.6",
"@ff-labs/fff-bun": "0.10.1",
"chalk": "^5.6.2",
"commander": "^14.0.3",
"commander": "^15.0.0",
"diff": "^9.0.0",
"dotenv": "^17.4.2",
"fs-extra": "^11.3.6",
"ignore": "^7.0.6",
"ink": "^7.1.0",
"ink": "^7.1.1",
"ink-spinner": "^5.0.0",
"minimatch": "^10.2.5",
"node-notifier": "^10.0.1",
"node-llama-cpp": "3.18.1",
"node-llama-cpp": "3.19.1",
"node-pty": "1.1.0",
"open": "^11.0.0",
"ora": "^9.4.1",
Expand All @@ -113,12 +113,13 @@
"@types/node-notifier": "^8.0.5",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.17",
"@typescript/native": "npm:typescript@^7.0.2",
"@typescript-eslint/eslint-plugin": "^8.64.0",
"@typescript-eslint/parser": "^8.64.0",
"eslint": "^10.7.0",
"ink-testing-library": "^4.0.0",
"memfs": "^4.64.0",
"node-gyp": "^12.4.0",
"node-gyp": "^13.0.1",
"strip-ansi": "^7.2.0",
"tsup": "^8.5.1",
"tsx": "^4.23.1",
Expand Down
102 changes: 82 additions & 20 deletions src/modes/acp/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ import type {
PromptResponse,
SetSessionModeRequest,
SetSessionModeResponse,
SetSessionModelRequest,
SetSessionModelResponse,
ListSessionsRequest,
ListSessionsResponse,
ResumeSessionRequest,
Expand All @@ -31,7 +29,6 @@ import type {
McpServer,
SessionConfigOption,
SessionModeState,
SessionModelState,
SetSessionConfigOptionRequest,
SetSessionConfigOptionResponse,
ToolCallStatus,
Expand Down Expand Up @@ -75,6 +72,22 @@ interface AssistantReplayParts {
text?: string;
}

interface LegacySessionModelState {
availableModels: Array<{
modelId: string;
name: string;
}>;
currentModelId: string;
}

interface LegacySetSessionModelRequest {
sessionId: string;
modelId: string;
}

type LegacySetSessionModelResponse = Record<string, never>;
type ResponseWithLegacyModels<T> = T & { models: LegacySessionModelState };

const AUTOHAND_ACP_AUTH_METHODS: NonNullable<InitializeResponse['authMethods']> = [
{
id: 'autohand-setup',
Expand Down Expand Up @@ -187,14 +200,14 @@ export class AutohandAcpAdapter implements Agent {
} as SessionModeState;
}

private buildSessionModels(config: LoadedConfig, modelId: string): SessionModelState {
private buildSessionModels(config: LoadedConfig, modelId: string): LegacySessionModelState {
return {
availableModels: parseAvailableModels(config).map((m) => ({
modelId: m,
name: m.split('/').pop() ?? m,
})),
currentModelId: modelId,
} as SessionModelState;
};
}

private getSessionCommands(config: LoadedConfig): AcpCommand[] {
Expand All @@ -211,6 +224,15 @@ export class AutohandAcpAdapter implements Agent {
return this.cloneConfigOptions(options);
}

private updateModelConfigOption(sessionId: string, modelId: string): void {
const option = this.sessionConfigOptions
.get(sessionId)
?.find((entry) => entry.id === 'model');
if (option?.type === 'select') {
option.currentValue = modelId;
}
}

private validateMode(modeId: string): void {
if (!DEFAULT_ACP_MODES.some((mode) => mode.id === modeId)) {
throw RequestError.invalidParams({ message: `Unsupported mode: ${modeId}` });
Expand Down Expand Up @@ -241,6 +263,12 @@ export class AutohandAcpAdapter implements Agent {
};
}

if (server.type === 'acp') {
throw RequestError.invalidParams({
message: `ACP-channel MCP server "${server.name}" is not supported by this adapter`,
});
}

return {
name: server.name,
transport: server.type,
Expand Down Expand Up @@ -402,8 +430,8 @@ export class AutohandAcpAdapter implements Agent {
await this.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'thinking', text: replayParts.thought },
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text: replayParts.thought },
},
});
}
Expand Down Expand Up @@ -459,6 +487,7 @@ export class AutohandAcpAdapter implements Agent {

if (loadedSession.metadata.model) {
state.modelId = loadedSession.metadata.model;
this.updateModelConfigOption(sessionId, state.modelId);
}

this.sessions.set(sessionId, state);
Expand Down Expand Up @@ -536,14 +565,14 @@ export class AutohandAcpAdapter implements Agent {
// ACP Agent Interface: newSession
// ==========================================================================

async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
async newSession(params: NewSessionRequest): Promise<ResponseWithLegacyModels<NewSessionResponse>> {
const sessionId = crypto.randomUUID();
const workspaceRoot = this.resolveWorkspaceRoot(sessionId, params.cwd);
const { config, state, agent } = await this.createManagedSession(sessionId, workspaceRoot);
await this.connectSessionMcpServers(agent, params.mcpServers);
this.emitHookSessionStart(sessionId, 'startup');

const response: NewSessionResponse = {
const response: ResponseWithLegacyModels<NewSessionResponse> = {
sessionId,
modes: this.buildSessionModes(state.modeId),
models: this.buildSessionModels(config, state.modelId),
Expand Down Expand Up @@ -751,7 +780,9 @@ export class AutohandAcpAdapter implements Agent {
// ACP Agent Interface: unstable_setSessionModel
// ==========================================================================

async unstable_setSessionModel(params: SetSessionModelRequest): Promise<SetSessionModelResponse> {
async unstable_setSessionModel(
params: LegacySetSessionModelRequest,
): Promise<LegacySetSessionModelResponse> {
const session = this.sessions.get(params.sessionId);
const agent = this.agents.get(params.sessionId);
if (!session) {
Expand All @@ -764,17 +795,19 @@ export class AutohandAcpAdapter implements Agent {
this.validateModel(config, params.modelId);

session.modelId = params.modelId;
this.updateModelConfigOption(params.sessionId, params.modelId);
agent.applyAcpModel(params.modelId);
process.stderr.write(`[ACP] Session ${params.sessionId} model set to: ${params.modelId}\n`);
return {};
}

async unstable_setSessionConfigOption(
async setSessionConfigOption(
params: SetSessionConfigOptionRequest
): Promise<SetSessionConfigOptionResponse> {
const options = this.sessionConfigOptions.get(params.sessionId);
const agent = this.agents.get(params.sessionId);
if (!options || !agent) {
const session = this.sessions.get(params.sessionId);
if (!options || !agent || !session) {
throw RequestError.invalidParams({ message: 'Session not found' });
}

Expand Down Expand Up @@ -802,18 +835,33 @@ export class AutohandAcpAdapter implements Agent {
}

option.currentValue = params.value;
agent.applyAcpConfigOption(params.configId, String(params.value));
if (params.configId === 'model') {
const modelId = String(params.value);
const config = await this.ensureConfig();
this.validateModel(config, modelId);
session.modelId = modelId;
agent.applyAcpModel(modelId);
process.stderr.write(`[ACP] Session ${params.sessionId} model set to: ${modelId}\n`);
} else {
agent.applyAcpConfigOption(params.configId, String(params.value));
}

return {
configOptions: this.cloneConfigOptions(options),
};
}

async unstable_setSessionConfigOption(
params: SetSessionConfigOptionRequest,
): Promise<SetSessionConfigOptionResponse> {
return this.setSessionConfigOption(params);
}

// ==========================================================================
// ACP Agent Interface: unstable_listSessions (optional)
// ACP Agent Interface: listSessions (optional)
// ==========================================================================

async unstable_listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
// Delegate to SessionManager for persistent session listing
try {
const { SessionManager } = await import('../../session/SessionManager.js');
Expand Down Expand Up @@ -847,11 +895,17 @@ export class AutohandAcpAdapter implements Agent {
}
}

async unstable_listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
return this.listSessions(params);
}

// ==========================================================================
// ACP Agent Interface: unstable_resumeSession (optional)
// ACP Agent Interface: resumeSession (optional)
// ==========================================================================

async unstable_resumeSession(_params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
async resumeSession(
_params: ResumeSessionRequest,
): Promise<ResponseWithLegacyModels<ResumeSessionResponse>> {
try {
const params = _params;
const { config, state } = await this.restoreSession(params.sessionId, params.cwd, params.mcpServers);
Expand All @@ -869,6 +923,12 @@ export class AutohandAcpAdapter implements Agent {
}
}

async unstable_resumeSession(
params: ResumeSessionRequest,
): Promise<ResponseWithLegacyModels<ResumeSessionResponse>> {
return this.resumeSession(params);
}

// ==========================================================================
// ACP Agent Interface: unstable_forkSession (optional)
// ==========================================================================
Expand Down Expand Up @@ -912,7 +972,9 @@ export class AutohandAcpAdapter implements Agent {
// ACP Agent Interface: loadSession (optional)
// ==========================================================================

async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
async loadSession(
params: LoadSessionRequest,
): Promise<ResponseWithLegacyModels<LoadSessionResponse>> {
try {
const { config, state, messages } = await this.restoreSession(params.sessionId, params.cwd, params.mcpServers);
await this.replayConversation(params.sessionId, messages);
Expand Down Expand Up @@ -1044,9 +1106,9 @@ export class AutohandAcpAdapter implements Agent {
await this.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: 'agent_message_chunk',
sessionUpdate: 'agent_thought_chunk',
content: {
type: 'thinking',
type: 'text',
text: event.thought,
},
},
Expand Down
15 changes: 14 additions & 1 deletion src/modes/acp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,10 +386,23 @@ export interface AcpSessionState {
* Build ACP config options from the loaded config.
*/
export function buildConfigOptions(
_config: LoadedConfig,
config: LoadedConfig,
): SessionConfigOption[] {
const options: SessionConfigOption[] = [];

options.push({
type: "select",
id: "model",
name: "Model",
description: "Select the model used for this session",
category: "model",
options: parseAvailableModels(config).map((modelId) => ({
value: modelId,
name: modelId.split("/").pop() ?? modelId,
})),
currentValue: resolveDefaultModel(config),
});

// Thinking level
options.push({
type: "select",
Expand Down
6 changes: 4 additions & 2 deletions tests/installLocalScript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ describe('local install scripts', () => {
};
const proofScript = packageJson.scripts?.proof ?? '';
const unitProofScript = packageJson.scripts?.['proof:unit'] ?? '';
const typecheckScript = packageJson.scripts?.typecheck ?? '';

expect(proofScript).toBe('bun run proof:unit && bun run proof:build-tuistory');
expect(unitProofScript).toBe('eslint . && tsc --noEmit && node --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run');
expect(unitProofScript).not.toContain('bun run');
expect(typecheckScript).toBe('node ./node_modules/@typescript/native/bin/tsc --noEmit');
expect(unitProofScript).toBe('eslint . && bun run typecheck && node --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run');
expect(unitProofScript).not.toContain('tsc --noEmit');
});

it('runs dev through a portable minimal bun environment', () => {
Expand Down
Loading