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
92 changes: 92 additions & 0 deletions src/lib/cloud-agent-next/run-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Tests for run-session.ts
*
* Tests the pure helper functions (resolveStreamUrl, extractTextFromMessage)
* exported from run-session.ts.
*/

import { resolveStreamUrl, extractTextFromMessage } from './run-session';
import type { ProcessedMessage } from './processor';

// ─── resolveStreamUrl ───────────────────────────────────────────────

describe('resolveStreamUrl', () => {
it('throws when streamUrl is empty', () => {
expect(() => resolveStreamUrl('')).toThrow('Cloud Agent stream URL is missing');
});

it('returns an absolute wss:// URL when given an absolute https:// URL', () => {
const result = resolveStreamUrl('https://agent.example.com/stream?id=123');
expect(result).toBe('wss://agent.example.com/stream?id=123');
});

it('returns an absolute ws:// URL when given an absolute http:// URL', () => {
const result = resolveStreamUrl('http://agent.example.com/stream');
expect(result).toBe('ws://agent.example.com/stream');
});

it('passes through an already-wss URL unchanged', () => {
const result = resolveStreamUrl('wss://agent.example.com/stream');
expect(result).toBe('wss://agent.example.com/stream');
});
});

// ─── extractTextFromMessage ─────────────────────────────────────────

function makeMessage(parts: ProcessedMessage['parts']): ProcessedMessage {
return {
info: {

Check failure on line 38 in src/lib/cloud-agent-next/run-session.test.ts

View workflow job for this annotation

GitHub Actions / typecheck

Type '{ id: string; sessionID: string; role: "assistant"; time: { created: number; }; }' is not assignable to type 'AssistantMessage | UserMessage'.
id: 'msg-1',
sessionID: 'session-1',
role: 'assistant',
time: { created: Date.now() },
},
parts,
};
}

describe('extractTextFromMessage', () => {
it('returns empty string when message has no parts', () => {
expect(extractTextFromMessage(makeMessage([]))).toBe('');
});

it('extracts text from a single text part', () => {
const message = makeMessage([
{ id: 'p1', sessionID: 's1', messageID: 'm1', type: 'text', text: 'PR opened at https://github.com/org/repo/pull/42' },
]);
expect(extractTextFromMessage(message)).toBe(
'PR opened at https://github.com/org/repo/pull/42'
);
});

it('concatenates multiple text parts', () => {
const message = makeMessage([
{ id: 'p1', sessionID: 's1', messageID: 'm1', type: 'text', text: 'Changes applied. ' },
{ id: 'p2', sessionID: 's1', messageID: 'm1', type: 'text', text: 'PR: https://github.com/org/repo/pull/42' },
]);
expect(extractTextFromMessage(message)).toBe(
'Changes applied. PR: https://github.com/org/repo/pull/42'
);
});

it('ignores non-text parts like tool parts', () => {
const message = makeMessage([
{

Check failure on line 74 in src/lib/cloud-agent-next/run-session.test.ts

View workflow job for this annotation

GitHub Actions / typecheck

Conversion of type '{ id: string; sessionID: string; messageID: string; type: "tool"; call: { id: string; name: string; input: string; }; }' to type 'Part' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
id: 'p1',
sessionID: 's1',
messageID: 'm1',
type: 'tool',
call: { id: 'call-1', name: 'bash', input: '{}' },
} as ProcessedMessage['parts'][number],
{ id: 'p2', sessionID: 's1', messageID: 'm1', type: 'text', text: 'Done!' },
]);
expect(extractTextFromMessage(message)).toBe('Done!');
});

it('trims whitespace from the final result', () => {
const message = makeMessage([
{ id: 'p1', sessionID: 's1', messageID: 'm1', type: 'text', text: ' Hello ' },
]);
expect(extractTextFromMessage(message)).toBe('Hello');
});
});
24 changes: 20 additions & 4 deletions src/lib/cloud-agent-next/run-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,10 @@ export type RunSessionResult = {
response: string;
/** The cloud-agent session ID (available even on failure). */
sessionId?: string;
/** Whether the session encountered an error. */
/** Whether the session encountered a fatal error (stream/WS/session-level). */
hasError: boolean;
/** Whether stderr output was observed (informational; does NOT imply failure). */
hasStderr: boolean;
/** Collected status/error messages for diagnostics. */
statusMessages: string[];
};
Expand Down Expand Up @@ -157,6 +159,7 @@ export async function runSessionToCompletion(input: RunSessionInput): Promise<Ru
let sessionId: string | undefined;
let kiloSessionId: string | undefined;
let hasError = false;
let hasStderr = false;
let errorMessage: string | undefined;

// 1. Prepare
Expand All @@ -171,14 +174,15 @@ export async function runSessionToCompletion(input: RunSessionInput): Promise<Ru
response: `Error preparing Cloud Agent: ${msg}`,
sessionId,
hasError: true,
hasStderr: false,
statusMessages,
};
}

if (!sessionId || !kiloSessionId) {
const msg = 'Session preparation did not return session IDs.';
console.error(`${logPrefix} ${msg}`);
return { response: msg, sessionId, hasError: true, statusMessages };
return { response: msg, sessionId, hasError: true, hasStderr: false, statusMessages };
}

// 2. Initiate
Expand All @@ -196,6 +200,7 @@ export async function runSessionToCompletion(input: RunSessionInput): Promise<Ru
response: `Error initiating Cloud Agent: ${msg}`,
sessionId,
hasError: true,
hasStderr: false,
statusMessages,
};
}
Expand All @@ -218,6 +223,7 @@ export async function runSessionToCompletion(input: RunSessionInput): Promise<Ru
response: `Error resolving stream URL: ${msg}`,
sessionId,
hasError: true,
hasStderr: false,
statusMessages,
};
}
Expand Down Expand Up @@ -315,7 +321,7 @@ export async function runSessionToCompletion(input: RunSessionInput): Promise<Ru
const data = event.data as { source?: string; content?: string };
if (data?.source === 'stderr') {
statusMessages.push(`[stderr] ${data.content ?? ''}`.trim());
hasError = true;
hasStderr = true;
}
break;
}
Expand Down Expand Up @@ -365,12 +371,20 @@ export async function runSessionToCompletion(input: RunSessionInput): Promise<Ru
);

// 7. Build result
//
// When the assistant produced a completionResult (e.g. containing a PR/MR
// URL), prefer returning it even if a fatal error also occurred — losing the
// PR link is the worse outcome for users.
if (hasError) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Non-zero agent exits still bypass this error path

This branch only runs when hasError was set earlier, but the complete handler never marks a non-zero exitCode as fatal. After changing stderr to only flip hasStderr, a session that exits with code 1 and only writes to stderr will now fall through to the success response, so users won't see that the run actually failed. Treating complete.data.exitCode !== 0 as an error would preserve the original failure signal while still allowing completionResult to be appended here.

const details = [errorMessage, ...statusMessages].filter(Boolean).join('\n');
const errorSummary = `Cloud Agent session ${sessionId} encountered errors:\n${details}`;
return {
response: `Cloud Agent session ${sessionId} encountered errors:\n${details}`,
response: completionResult
? `${errorSummary}\n\nHowever, the agent produced this output:\n\n${completionResult}`
: errorSummary,
sessionId,
hasError: true,
hasStderr,
statusMessages,
};
}
Expand All @@ -380,6 +394,7 @@ export async function runSessionToCompletion(input: RunSessionInput): Promise<Ru
response: `Cloud Agent session ${sessionId} completed:\n\n${completionResult}`,
sessionId,
hasError: false,
hasStderr,
statusMessages,
};
}
Expand All @@ -388,6 +403,7 @@ export async function runSessionToCompletion(input: RunSessionInput): Promise<Ru
response: `Cloud Agent session ${sessionId} completed successfully.\n\nStatus:\n${statusMessages.slice(-5).join('\n')}`,
sessionId,
hasError: false,
hasStderr,
statusMessages,
};
}
Loading