Add emitLastMessage flag to agent.continue()

When calling continue() with emitLastMessage=true, the agent loop
emits message_start/message_end events for the last message in context.
This allows messages added outside the loop (e.g., hook messages via
sendHookMessage) to trigger proper TUI rendering.

Changes across packages:
- packages/ai: agentLoopContinue() accepts emitLastMessage parameter
- packages/agent: Agent.continue(), transports updated to pass flag
- packages/coding-agent: sendHookMessage passes true when triggerTurn
This commit is contained in:
Mario Zechner 2025-12-27 01:31:31 +01:00
parent 30cd723411
commit 357bd946c2
6 changed files with 34 additions and 12 deletions

View file

@ -206,7 +206,12 @@ export class Agent {
* Continue from the current context without adding a new user message. * Continue from the current context without adding a new user message.
* Used for retry after overflow recovery when context already has user message or tool results. * Used for retry after overflow recovery when context already has user message or tool results.
*/ */
async continue() { /**
* Continue from the current context without adding a new user message.
* Used for retry after overflow recovery when context already has user message or tool results.
* @param emitLastMessage If true, emit message_start/message_end for the last message
*/
async continue(emitLastMessage?: boolean) {
const messages = this._state.messages; const messages = this._state.messages;
if (messages.length === 0) { if (messages.length === 0) {
throw new Error("No messages to continue from"); throw new Error("No messages to continue from");
@ -217,7 +222,7 @@ export class Agent {
throw new Error(`Cannot continue from message role: ${lastMessage.role}`); throw new Error(`Cannot continue from message role: ${lastMessage.role}`);
} }
await this._runAgentLoopContinue(); await this._runAgentLoopContinue(emitLastMessage);
} }
/** /**
@ -234,10 +239,10 @@ export class Agent {
/** /**
* Internal: Continue the agent loop from current context. * Internal: Continue the agent loop from current context.
*/ */
private async _runAgentLoopContinue() { private async _runAgentLoopContinue(emitLastMessage?: boolean) {
const { llmMessages, cfg } = await this._prepareRun(); const { llmMessages, cfg } = await this._prepareRun();
const events = this.transport.continue(llmMessages, cfg, this.abortController!.signal); const events = this.transport.continue(llmMessages, cfg, this.abortController!.signal, emitLastMessage);
await this._processEvents(events); await this._processEvents(events);
} }

View file

@ -380,7 +380,7 @@ export class AppTransport implements AgentTransport {
} }
} }
async *continue(messages: Message[], cfg: AgentRunConfig, signal?: AbortSignal) { async *continue(messages: Message[], cfg: AgentRunConfig, signal?: AbortSignal, emitLastMessage?: boolean) {
const authToken = await this.options.getAuthToken(); const authToken = await this.options.getAuthToken();
if (!authToken) { if (!authToken) {
throw new Error("Auth token is required for AppTransport"); throw new Error("Auth token is required for AppTransport");
@ -390,7 +390,7 @@ export class AppTransport implements AgentTransport {
const context = this.buildContext(messages, cfg); const context = this.buildContext(messages, cfg);
const pc = this.buildLoopConfig(cfg); const pc = this.buildLoopConfig(cfg);
for await (const ev of agentLoopContinue(context, pc, signal, streamFn as any)) { for await (const ev of agentLoopContinue(context, pc, signal, streamFn as any, emitLastMessage)) {
yield ev; yield ev;
} }
} }

View file

@ -73,12 +73,12 @@ export class ProviderTransport implements AgentTransport {
} }
} }
async *continue(messages: Message[], cfg: AgentRunConfig, signal?: AbortSignal) { async *continue(messages: Message[], cfg: AgentRunConfig, signal?: AbortSignal, emitLastMessage?: boolean) {
const model = this.getModel(cfg); const model = this.getModel(cfg);
const context = this.buildContext(messages, cfg); const context = this.buildContext(messages, cfg);
const pc = this.buildLoopConfig(model, cfg); const pc = this.buildLoopConfig(model, cfg);
for await (const ev of agentLoopContinue(context, pc, signal)) { for await (const ev of agentLoopContinue(context, pc, signal, undefined, emitLastMessage)) {
yield ev; yield ev;
} }
} }

View file

@ -28,5 +28,10 @@ export interface AgentTransport {
): AsyncIterable<AgentEvent>; ): AsyncIterable<AgentEvent>;
/** Continue from current context (no new user message) */ /** Continue from current context (no new user message) */
continue(messages: Message[], config: AgentRunConfig, signal?: AbortSignal): AsyncIterable<AgentEvent>; continue(
messages: Message[],
config: AgentRunConfig,
signal?: AbortSignal,
emitLastMessage?: boolean,
): AsyncIterable<AgentEvent>;
} }

View file

@ -40,11 +40,18 @@ export function agentLoop(
* Used for retry after overflow - context already has user message or tool results. * Used for retry after overflow - context already has user message or tool results.
* Throws if the last message is not a user message or tool result. * Throws if the last message is not a user message or tool result.
*/ */
/**
* Continue an agent loop from the current context without adding a new message.
* Used for retry after overflow - context already has user message or tool results.
* Throws if the last message is not a user message or tool result.
* @param emitLastMessage If true, emit message_start/message_end for the last message in context
*/
export function agentLoopContinue( export function agentLoopContinue(
context: AgentContext, context: AgentContext,
config: AgentLoopConfig, config: AgentLoopConfig,
signal?: AbortSignal, signal?: AbortSignal,
streamFn?: typeof streamSimple, streamFn?: typeof streamSimple,
emitLastMessage?: boolean,
): EventStream<AgentEvent, AgentContext["messages"]> { ): EventStream<AgentEvent, AgentContext["messages"]> {
// Validate that we can continue from this context // Validate that we can continue from this context
const lastMessage = context.messages[context.messages.length - 1]; const lastMessage = context.messages[context.messages.length - 1];
@ -63,7 +70,12 @@ export function agentLoopContinue(
stream.push({ type: "agent_start" }); stream.push({ type: "agent_start" });
stream.push({ type: "turn_start" }); stream.push({ type: "turn_start" });
// No user message events - we're continuing from existing context
// Optionally emit events for the last message (used when message was added outside the loop)
if (emitLastMessage) {
stream.push({ type: "message_start", message: lastMessage });
stream.push({ type: "message_end", message: lastMessage });
}
await runLoop(currentContext, newMessages, config, signal, stream, streamFn); await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
})(); })();

View file

@ -602,8 +602,8 @@ export class AgentSession {
message.display, message.display,
message.details, message.details,
); );
// Start a new turn - agent.continue() works because last message is user role // Start a new turn - emit message events for the hook message so TUI can render it
await this.agent.continue(); await this.agent.continue(true);
} else { } else {
// Just append to agent state and session, no turn // Just append to agent state and session, no turn
this.agent.appendMessage(appMessage); this.agent.appendMessage(appMessage);