Skip to content

Commit 1aff551

Browse files
committed
feat: add auto-mode iteration events
1 parent 9bcbb7e commit 1aff551

4 files changed

Lines changed: 115 additions & 4 deletions

File tree

src/__tests__/extension-features-e2e.test.ts

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ async function createFeatureCli(options: {
1010
method: string;
1111
params: Record<string, unknown>;
1212
result: unknown;
13-
notification?: { method: string; params: Record<string, unknown> };
13+
notifications?: Array<{ method: string; params: Record<string, unknown> }>;
1414
}): Promise<string> {
1515
const directory = await mkdtemp(join(tmpdir(), 'autohand-ts-features-'));
1616
temporaryDirectories.push(directory);
@@ -36,11 +36,11 @@ for await (const line of lines) {
3636
messageCount: 0,
3737
},
3838
}) + '\\n');
39-
if (fixture.notification !== undefined) {
39+
for (const notification of fixture.notifications ?? []) {
4040
process.stdout.write(JSON.stringify({
4141
jsonrpc: '2.0',
42-
method: fixture.notification.method,
43-
params: fixture.notification.params,
42+
method: notification.method,
43+
params: notification.params,
4444
}) + '\\n');
4545
}
4646
continue;
@@ -80,6 +80,15 @@ async function withSDK<T>(
8080
}
8181
}
8282

83+
async function nextNotification(sdk: AutohandSDK) {
84+
const events = sdk.events();
85+
const next = events.next();
86+
await sdk.getState();
87+
const event = await next;
88+
await events.return(undefined);
89+
return event.value;
90+
}
91+
8392
afterEach(async () => {
8493
await Promise.all(temporaryDirectories.splice(0).map((directory) =>
8594
rm(directory, { recursive: true, force: true })
@@ -595,3 +604,58 @@ describe('extension RPC features', () => {
595604
)).rejects.toThrow(/Invalid RPC result for autohand\.setContextCompact/);
596605
});
597606
});
607+
608+
describe('extension notification features', () => {
609+
it('streams typed auto-mode iteration events from the spawned CLI', async () => {
610+
const notification = {
611+
method: 'autohand.automode.iteration',
612+
params: {
613+
sessionId: 'automode-1',
614+
iteration: 3,
615+
actions: ['edited src/index.ts', 'ran tests'],
616+
tokensUsed: 1_250,
617+
timestamp: '2026-07-20T00:03:00.000Z',
618+
},
619+
};
620+
const sentinel = {
621+
method: 'autohand.error',
622+
params: { code: 500, message: 'sentinel', recoverable: true, timestamp: 'sentinel' },
623+
};
624+
625+
await expect(withSDK({
626+
method: 'unused',
627+
params: {},
628+
result: {},
629+
notifications: [notification, sentinel],
630+
}, nextNotification)).resolves.toEqual({
631+
type: 'automode_iteration',
632+
...notification.params,
633+
});
634+
});
635+
636+
it('drops malformed auto-mode iterations without hiding later valid events', async () => {
637+
const malformed = {
638+
method: 'autohand.automode.iteration',
639+
params: {
640+
sessionId: 'automode-1',
641+
iteration: 'three',
642+
actions: [],
643+
timestamp: '2026-07-20T00:03:00.000Z',
644+
},
645+
};
646+
const sentinel = {
647+
method: 'autohand.error',
648+
params: { code: 500, message: 'sentinel', recoverable: true, timestamp: 'sentinel' },
649+
};
650+
651+
await expect(withSDK({
652+
method: 'unused',
653+
params: {},
654+
result: {},
655+
notifications: [malformed, sentinel],
656+
}, nextNotification)).resolves.toEqual({
657+
type: 'error',
658+
...sentinel.params,
659+
});
660+
});
661+
});

src/rpc/client.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ import type {
125125
import { detectProviderFromModel, validateProviderConfig, getSkillName, getSkillPath } from '../types/index.js';
126126
import { validateSessionControlRpcResult } from '../validation/session-control-rpc-results.js';
127127
import { validateExtensionRpcResult } from '../validation/extension-rpc-results.js';
128+
import { parseAutomodeIterationEvent } from '../validation/extension-notifications.js';
128129

129130
function scopedDecision(
130131
allowed: boolean,
@@ -1069,6 +1070,11 @@ export class RPCClient {
10691070
this.queueEvent(event);
10701071
});
10711072

1073+
this.transport.onNotification('autohand.automode.iteration', (params) => {
1074+
const event = parseAutomodeIterationEvent(params);
1075+
if (event !== undefined) this.queueEvent(event);
1076+
});
1077+
10721078
const queueAutoresearchEvent = (phase: AutoresearchLifecycleEvent['phase'], params: unknown): void => {
10731079
const p = params as Omit<AutoresearchLifecycleEvent, 'type' | 'phase'>;
10741080
this.queueEvent({

src/types/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2340,10 +2340,20 @@ export type SDKEvent =
23402340
| ToolEndEvent
23412341
| FileModifiedEvent
23422342
| PermissionRequestEvent
2343+
| AutomodeIterationEvent
23432344
| AutoresearchEvent
23442345
| AutoresearchOperationEvent
23452346
| ErrorEvent;
23462347

2348+
export interface AutomodeIterationEvent {
2349+
type: 'automode_iteration';
2350+
sessionId: string;
2351+
iteration: number;
2352+
actions: string[];
2353+
tokensUsed?: number;
2354+
timestamp: string;
2355+
}
2356+
23472357
export interface AutoresearchEvent {
23482358
type: 'autoresearch';
23492359
phase: 'start' | 'status' | 'pause';
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { AutomodeIterationEvent } from '../types/index.js';
2+
3+
function isRecord(value: unknown): value is Record<string, unknown> {
4+
return typeof value === 'object' && value !== null && !Array.isArray(value);
5+
}
6+
7+
function isStringArray(value: unknown): value is string[] {
8+
return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
9+
}
10+
11+
/** Parse a CLI auto-mode iteration notification at the transport trust boundary. */
12+
export function parseAutomodeIterationEvent(value: unknown): AutomodeIterationEvent | undefined {
13+
if (!isRecord(value)
14+
|| typeof value.sessionId !== 'string'
15+
|| typeof value.iteration !== 'number'
16+
|| !Number.isFinite(value.iteration)
17+
|| !isStringArray(value.actions)
18+
|| typeof value.timestamp !== 'string'
19+
|| (value.tokensUsed !== undefined
20+
&& (typeof value.tokensUsed !== 'number' || !Number.isFinite(value.tokensUsed)))) {
21+
return undefined;
22+
}
23+
return {
24+
type: 'automode_iteration',
25+
sessionId: value.sessionId,
26+
iteration: value.iteration,
27+
actions: value.actions,
28+
...(value.tokensUsed !== undefined ? { tokensUsed: value.tokensUsed } : {}),
29+
timestamp: value.timestamp,
30+
};
31+
}

0 commit comments

Comments
 (0)