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
76 changes: 70 additions & 6 deletions packages/cloud-agent-sdk/src/normalizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -967,27 +967,79 @@ describe('normalize', () => {
});

describe('question.asked', () => {
const question = {
question: 'Continue?',
header: 'Approval',
options: [{ label: 'Yes', description: 'Run the command' }],
};

it('extracts requestId and questions from tool.callID', () => {
const data = {
id: 'q-1',
tool: { callID: 'call-1' },
questions: [{ text: 'Continue?' }],
questions: [question],
};
const result = normalize(createRaw('question.asked', data));
expect(result).toEqual({
type: 'question.asked',
requestId: 'q-1',
questions: [{ text: 'Continue?' }],
questions: [question],
});
});

it('works when tool is absent', () => {
const data = { id: 'q-1', questions: [{ text: 'Yes?' }] };
const data = { id: 'q-1', questions: [question] };
const result = normalize(createRaw('question.asked', data));
expect(result).toEqual({
type: 'question.asked',
requestId: 'q-1',
questions: [question],
});
});

it('keeps the valid prefix and stops at the first malformed entry', () => {
const data = {
id: 'q-1',
questions: [question, { text: 'Continue?' }, question],
};
const result = normalize(createRaw('question.asked', data));
expect(result).toEqual({
type: 'question.asked',
requestId: 'q-1',
questions: [question],
});
});

it('keeps only the shared QuestionInfo fields', () => {
const data = {
id: 'q-1',
questions: [{ ...question, extra: 'ignored' }],
};
const result = normalize(createRaw('question.asked', data));
expect(result).toEqual({
type: 'question.asked',
requestId: 'q-1',
questions: [{ text: 'Yes?' }],
questions: [question],
});
});

it('treats a list whose first entry is malformed as carrying no questions', () => {
const data = { id: 'q-1', questions: [{ header: 'Approval' }, question] };
const result = normalize(createRaw('question.asked', data));
expect(result).toEqual({
type: 'question.asked',
requestId: 'q-1',
questions: undefined,
});
});

it('keeps an explicitly empty questions array', () => {
const data = { id: 'q-1', questions: [] };
const result = normalize(createRaw('question.asked', data));
expect(result).toEqual({
type: 'question.asked',
requestId: 'q-1',
questions: [],
});
});

Expand Down Expand Up @@ -2332,12 +2384,24 @@ describe('normalizeCliEvent', () => {
const data = {
id: 'q-1',
tool: { callID: 'call-1' },
questions: [{ text: 'Continue?' }],
questions: [
{
question: 'Continue?',
header: 'Approval',
options: [{ label: 'Yes', description: '' }],
},
],
};
expect(normalizeCliEvent('question.asked', data)).toEqual({
type: 'question.asked',
requestId: 'q-1',
questions: [{ text: 'Continue?' }],
questions: [
{
question: 'Continue?',
header: 'Approval',
options: [{ label: 'Yes', description: '' }],
},
],
});
});
});
Expand Down
2 changes: 1 addition & 1 deletion packages/cloud-agent-sdk/src/normalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,7 @@ function normalizeInnerEvent(eventType: string, data: unknown): NormalizedEvent
return {
type: 'question.asked',
requestId: r.data.id,
questions: r.data.questions as QuestionInfo[] | undefined,
questions: r.data.questions,
};
}

Expand Down
55 changes: 54 additions & 1 deletion packages/cloud-agent-sdk/src/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as z from 'zod';
import { sessionIdSchema } from '@kilocode/session-ingest-contracts';
import type { QuestionInfo } from '@kilocode/app-shared/opencode';
import { sortRemoteModelCatalogProviders } from './remote-model-order';
import type { KiloSessionId, SlashCommandCatalogStatus } from './types';

Expand Down Expand Up @@ -764,10 +765,62 @@ export const sessionQueueChangedDataSchema = z
.passthrough();
export type SessionQueueChangedData = z.infer<typeof sessionQueueChangedDataSchema>;

/** Runtime mirror of `QuestionInfo` in `@kilocode/app-shared/opencode`. */
const questionOptionSchema = z.object({
label: z.string(),
description: z.string(),
mode: z.string().optional(),
});

const questionInfoSchema = z.object({
question: z.string(),
header: z.string(),
options: z.array(questionOptionSchema),
multiple: z.boolean().optional(),
custom: z.boolean().optional(),
});

/**
* Convert a parsed entry to the shared type. Zod types an absent optional
* property as `T | undefined`, which `exactOptionalPropertyTypes` rejects
* against `QuestionInfo`, so the optionals are only added when present.
*/
function toQuestionInfo(parsed: z.infer<typeof questionInfoSchema>): QuestionInfo {
return {
question: parsed.question,
header: parsed.header,
options: parsed.options.map(option => ({
label: option.label,
description: option.description,
...(option.mode === undefined ? {} : { mode: option.mode }),
})),
...(parsed.multiple === undefined ? {} : { multiple: parsed.multiple }),
...(parsed.custom === undefined ? {} : { custom: parsed.custom }),
};
}

/**
* A question list is positional: the answer payload mirrors the CLI's question
* indices (`answerQuestion(requestId, answers: string[][])`). Keep the longest
* run of entries that match `QuestionInfo` and stop at the first entry that
* does not, so a rendered question keeps its CLI index instead of every later
* answer sliding onto the wrong question. No usable entry means the payload
* carries no questions the client can render.
*/
const questionListSchema = z.array(z.unknown()).transform((entries): QuestionInfo[] | undefined => {
const questions: QuestionInfo[] = [];
for (const entry of entries) {
const parsed = questionInfoSchema.safeParse(entry);
if (!parsed.success) break;
questions.push(toQuestionInfo(parsed.data));
}
return questions.length > 0 ? questions : entries.length > 0 ? undefined : [];
});

export const questionAskedDataSchema = z.object({
id: z.string(),
tool: z.object({ callID: z.string() }).optional(),
questions: z.array(z.unknown()).optional().catch(undefined),
questions: questionListSchema.optional().catch(undefined),
});
export type QuestionAskedData = z.infer<typeof questionAskedDataSchema>;

Expand Down
71 changes: 71 additions & 0 deletions packages/cloud-agent-sdk/src/session-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6981,6 +6981,36 @@ describe('createSessionManager', () => {
expect(mockSession.state.clearFailedMessage).toHaveBeenCalledWith('m1');
});

it('clears the terminal error when the retried failure held it', async () => {
const config = createMockConfig();
const mgr = createSessionManager(config);

await switchAndCaptureSubscriber(config, mgr);

config.store.set(mgr.atoms.error, 'The message could not be delivered');
mockSession.state.clearFailedMessage.mockReturnValueOnce(true);

mgr.clearFailedMessage('m1');

// The service undid the failure that reached `errorAtom` through
// `config.onError`, so the banner must not outlive the accepted retry.
expect(atomValue<string | null>(config.store, mgr.atoms.error)).toBeNull();
});

it('keeps the banner when the retried failure did not hold the error', async () => {
const config = createMockConfig();
const mgr = createSessionManager(config);

await switchAndCaptureSubscriber(config, mgr);

config.store.set(mgr.atoms.error, 'Session terminated');
mockSession.state.clearFailedMessage.mockReturnValueOnce(false);

mgr.clearFailedMessage('m1');

expect(atomValue<string | null>(config.store, mgr.atoms.error)).toBe('Session terminated');
});

it('deletes a client-materialised failed row so a retry cannot duplicate it', async () => {
const config = createMockConfig();
const mgr = createSessionManager(config);
Expand Down Expand Up @@ -7746,6 +7776,47 @@ describe('createSessionManager', () => {
expect(atomValue<boolean>(config.store, mgr.atoms.hasOlderMessages)).toBe(false);
});

it('resets the omitted-item accounting so a cleared view reports no omitted history', async () => {
const fetchSnapshotPage = createPageFetchMock(async (id, options) => {
if (id !== 'ses-1') return makePage({ kiloSessionId: id });
if (!options.cursor) {
return makePage({
kiloSessionId: id,
messages: [makePageMessage('msg-initial', 'ses-1', 'initial')],
nextCursor: 'cursor-A',
omittedItemCount: 2,
});
}
return makePage({
kiloSessionId: id,
messages: [makePageMessage('msg-older', 'ses-1', 'older')],
nextCursor: null,
omittedItemCount: 3,
});
});
const cached = new Map<string, SessionSnapshotPage>();
const config = createMockConfig({
fetchSnapshotPage,
writeCachedSnapshotPage: page => cached.set(page.info.id, page),
readCachedSnapshotPage: async id => cached.get(id) ?? null,
fetchSession: async id => ({ ...defaultFetchedSession, kiloSessionId: id }),
});
const mgr = createSessionManager(config);

await mgr.switchSession(kiloId('ses-1'));
await mgr.loadOlderMessages();
expect(atomValue<number>(config.store, mgr.atoms.olderMessagesOmittedItemCount)).toBe(5);

mgr.clearTranscript();
expect(atomValue<number>(config.store, mgr.atoms.olderMessagesOmittedItemCount)).toBe(0);

// Leaving caches the cleared view; a stale count here would repaint the
// omitted banner on the next open of this session.
await mgr.switchSession(kiloId('ses-2'));
expect(cached.get('ses-1')?.omittedItemCount).toBe(0);
mgr.destroy();
});

it('purges all replayed history on reconnect with no post-clear send', async () => {
// E2E case 4: /clear → kill/reconnect, no send → view stays cleared.
const fetchSnapshotPage = jest.fn().mockResolvedValue({
Expand Down
14 changes: 13 additions & 1 deletion packages/cloud-agent-sdk/src/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3272,6 +3272,12 @@ function createSessionManager(config: SessionManagerConfig): SessionManager {
currentSession.state.clearCommits();
olderMessagesCursor = null;
store.set(hasOlderMessagesAtom, false);
// The cleared view holds nothing, so the omitted-history accounting starts
// from zero exactly as a non-preserved `clearAllAtoms` does: a stale count
// would keep the omitted banner up, and a stale initial-page baseline would
// subtract the wrong contribution from the next first page.
store.set(olderMessagesOmittedItemCountAtom, 0);
initialPageOmittedItemCount = 0;
// Reset the retained-history stack so a later `trimRetainedHistory`
// cannot restore a pre-clear cursor.
retainedHistoryStack = [];
Expand Down Expand Up @@ -3325,7 +3331,13 @@ function createSessionManager(config: SessionManagerConfig): SessionManager {
if (owner === null) return;
const ownerIsActive = owner === activeSessionId;
if (ownerIsActive) {
currentSession?.state.clearFailedMessage(messageId);
// The service reports when the pruned entry held the terminal error and
// has undone it. That error reached `errorAtom` through `config.onError`,
// which the service state cannot reach, so a retry that resolves the
// failure must clear it here — the durable-seed path does the same.
if (currentSession?.state.clearFailedMessage(messageId)) {
store.set(errorAtom, null);
}
// A client-materialised row is a local ghost: the accepted re-send
// supersedes its content and materialises its own row, so leaving the
// original would render the prompt twice — once for the failed
Expand Down