fix(omp): complete turns when agent_end omits messages (#2261)

* fix(omp): complete turns after empty agent_end

* test(omp): remove timing flush from empty end regression
This commit is contained in:
Byeonghoon Yoo
2026-07-23 04:42:16 +09:00
committed by GitHub
parent 5dfe50ca74
commit 35f5171477
4 changed files with 58 additions and 5 deletions

View File

@@ -132,6 +132,20 @@ describe("OMP agent client and session", () => {
]);
});
test("completes a streamed assistant turn when agent_end omits messages", async () => {
const omp = new OmpHarness();
await omp.start();
const { completion } = await omp.startPromptWithEmptyAgentEnd(
"hello OMP",
"empty terminal payload recovered",
);
await expect(completion).resolves.toMatchObject({
finalText: "empty terminal payload recovered",
});
expect(omp.completedTurnCount()).toBe(1);
});
test("does not accept a follow-up until OMP reports stable idle", async () => {
const omp = new OmpHarness();
await omp.start();

View File

@@ -899,6 +899,7 @@ export class OmpAgentSession implements AgentSession {
private activeTurnId: string | null = null;
private activeClientMessageId: string | null = null;
private activeAssistantMessageId: string | null = null;
private activeTurnTerminalAssistantMessage: OmpAgentMessage | null = null;
private activeTurnStarted = false;
private activeTurnHasUserMessage = false;
private activeNoTurnPromptText: string | null = null;
@@ -987,6 +988,7 @@ export class OmpAgentSession implements AgentSession {
this.activeTurnId = turnId;
this.activeClientMessageId = options?.clientMessageId ?? null;
this.activeAssistantMessageId = null;
this.activeTurnTerminalAssistantMessage = null;
this.activeTurnStarted = false;
this.activeTurnHasUserMessage = false;
this.activePromptRequestId = null;
@@ -1017,6 +1019,7 @@ export class OmpAgentSession implements AgentSession {
this.activeTurnStarted = false;
this.activeTurnHasUserMessage = false;
this.activeAssistantMessageId = null;
this.activeTurnTerminalAssistantMessage = null;
this.clearNoTurnBuffers();
if (isOmpRequestAbortError(error)) {
this.emit({
@@ -1151,6 +1154,7 @@ export class OmpAgentSession implements AgentSession {
this.activeTurnStarted = false;
this.activeTurnHasUserMessage = false;
this.activeAssistantMessageId = null;
this.activeTurnTerminalAssistantMessage = null;
this.clearNoTurnBuffers();
this.emit({
type: "turn_canceled",
@@ -1773,6 +1777,7 @@ export class OmpAgentSession implements AgentSession {
this.activeClientMessageId = null;
this.activeTurnStarted = false;
this.activeTurnHasUserMessage = false;
this.activeTurnTerminalAssistantMessage = null;
this.clearNoTurnBuffers();
this.emit({
type: "turn_failed",
@@ -1857,17 +1862,25 @@ export class OmpAgentSession implements AgentSession {
},
});
return;
case "agent_end":
case "agent_end": {
const messages = event.messages ?? [];
let terminalMessages: OmpAgentMessage[] | null = null;
if (messages.some((message) => message.role === "assistant")) {
terminalMessages = messages;
} else if (this.activeTurnTerminalAssistantMessage) {
terminalMessages = [this.activeTurnTerminalAssistantMessage];
}
// OMP can end an internal extension-notice cycle before it starts the
// model turn for the same prompt. That cycle has no assistant message
// and is not the foreground turn's terminal event.
if (!(event.messages ?? []).some((message) => message.role === "assistant")) {
// model turn for the same prompt. Ignore only cycles where neither the
// terminal payload nor the live stream contained an assistant message.
if (!terminalMessages) {
return;
}
// A state request is processed after OMP's RPC loop becomes promptable,
// so do not advertise Paseo idle until it reports that transition.
void this.completeTurnAfterProviderIdle(turnId, event.messages ?? []);
void this.completeTurnAfterProviderIdle(turnId, terminalMessages);
return;
}
default:
return;
}
@@ -1983,6 +1996,9 @@ export class OmpAgentSession implements AgentSession {
): void {
if (event.message.role === "assistant") {
this.activeAssistantMessageId = null;
if (turnId) {
this.activeTurnTerminalAssistantMessage = event.message;
}
return;
}
if (event.message.role === "custom") {
@@ -2097,6 +2113,7 @@ export class OmpAgentSession implements AgentSession {
this.activeTurnId = null;
this.activeClientMessageId = null;
this.activeAssistantMessageId = null;
this.activeTurnTerminalAssistantMessage = null;
this.activeTurnStarted = false;
this.activeTurnHasUserMessage = false;
this.clearNoTurnBuffers();

View File

@@ -413,6 +413,12 @@ export class FakeOmpSession implements OmpRuntimeSession {
this.emit({ type: "agent_end", messages: this.messages });
}
finishTurnWithEmptyAgentEnd(message: OmpAgentMessage = { role: "assistant", content: [] }): void {
this.messages = [...this.messages, message];
this.emit({ type: "message_end", message });
this.emit({ type: "agent_end", messages: [] });
}
beginTurn(): void {
this.emit({ type: "turn_start" });
}

View File

@@ -173,6 +173,22 @@ export class OmpHarness {
return await run;
}
async startPromptWithEmptyAgentEnd(
input: string,
output: string,
): Promise<{ completion: Promise<unknown> }> {
const session = this.requireSession();
const promptStarted = this.omp.latestSession().nextPrompt();
const completion = session.run(input);
await promptStarted;
const runtime = this.omp.latestSession();
runtime.beginTurn();
runtime.acceptPrompt(input, "user-1");
runtime.streamAssistantText(output);
runtime.finishTurnWithEmptyAgentEnd();
return { completion };
}
async runPromptAfterExtensionNotice(input: string, output: string): Promise<unknown> {
const session = this.requireSession();
const promptStarted = this.omp.latestSession().nextPrompt();