Skip to content

Commit 1c1ecae

Browse files
committed
fix(grok): Complete turns from xAI prompt completion
Address Bugbot review: prefer streaming turn for late notification chunks
1 parent a4964b3 commit 1c1ecae

6 files changed

Lines changed: 803 additions & 22 deletions

File tree

apps/server/scripts/acp-mock-agent.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ const emitInterleavedAssistantToolCalls =
1919
const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";
2020
const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";
2121
const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1";
22+
const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1";
23+
const emitStaleXAiPromptCompleteBeforeSecondHang =
24+
process.env.T3_ACP_EMIT_STALE_XAI_PROMPT_COMPLETE_BEFORE_SECOND_HANG === "1";
25+
const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1";
2226
const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
2327
const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
2428
const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
@@ -36,6 +40,7 @@ let parameterizedModelPicker = false;
3640
let currentReasoning = "medium";
3741
let currentContext = "272k";
3842
let currentFast = false;
43+
let promptCount = 0;
3944
const cancelledSessions = new Set<string>();
4045

4146
function logExit(reason: string): void {
@@ -45,6 +50,10 @@ function logExit(reason: string): void {
4550
NodeFS.appendFileSync(exitLogPath, `${reason}\n`, "utf8");
4651
}
4752

53+
function writeJsonRpcNotification(method: string, params: unknown): void {
54+
process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
55+
}
56+
4857
process.once("SIGTERM", () => {
4958
logExit("SIGTERM");
5059
process.exit(0);
@@ -364,11 +373,71 @@ const program = Effect.gen(function* () {
364373
yield* agent.handlePrompt((request) =>
365374
Effect.gen(function* () {
366375
const requestedSessionId = String(request.sessionId ?? sessionId);
376+
promptCount += 1;
367377

368378
if (Number.isFinite(promptDelayMs) && promptDelayMs > 0) {
369379
yield* Effect.sleep(`${promptDelayMs} millis`);
370380
}
371381

382+
if (failPrompt) {
383+
return yield* AcpError.AcpRequestError.internalError("Mock prompt failure");
384+
}
385+
386+
if (emitStaleXAiPromptCompleteBeforeSecondHang && promptCount === 1) {
387+
return {
388+
stopReason: "end_turn",
389+
_meta: {
390+
promptId: "mock-stale-xai-prompt-1",
391+
requestId: "mock-stale-xai-prompt-1",
392+
},
393+
};
394+
}
395+
396+
if (emitStaleXAiPromptCompleteBeforeSecondHang && promptCount === 2) {
397+
writeJsonRpcNotification("_x.ai/session/prompt_complete", {
398+
sessionId: requestedSessionId,
399+
promptId: "mock-stale-xai-prompt-1",
400+
stopReason: "end_turn",
401+
agentResult: null,
402+
});
403+
404+
writeJsonRpcNotification("_x.ai/session/prompt_complete", {
405+
sessionId: requestedSessionId,
406+
promptId: "mock-current-xai-prompt-2",
407+
stopReason: "end_turn",
408+
agentResult: null,
409+
});
410+
411+
return yield* Effect.never;
412+
}
413+
414+
if (emitXAiPromptCompleteThenHang) {
415+
writeJsonRpcNotification("session/update", {
416+
sessionId: requestedSessionId,
417+
update: {
418+
sessionUpdate: "agent_message_chunk",
419+
content: { type: "text", text: "hello from " },
420+
},
421+
});
422+
423+
writeJsonRpcNotification("_x.ai/session/prompt_complete", {
424+
sessionId: requestedSessionId,
425+
promptId: "mock-xai-prompt-1",
426+
stopReason: "end_turn",
427+
agentResult: null,
428+
});
429+
430+
writeJsonRpcNotification("session/update", {
431+
sessionId: requestedSessionId,
432+
update: {
433+
sessionUpdate: "agent_message_chunk",
434+
content: { type: "text", text: "mock" },
435+
},
436+
});
437+
438+
return yield* Effect.never;
439+
}
440+
372441
if (emitInterleavedAssistantToolCalls) {
373442
const toolCallId = "tool-call-1";
374443

apps/server/src/provider/Layers/GrokAdapter.test.ts

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import * as Deferred from "effect/Deferred";
1010
import * as Effect from "effect/Effect";
1111
import * as Fiber from "effect/Fiber";
1212
import * as Layer from "effect/Layer";
13+
import * as Ref from "effect/Ref";
1314
import * as Schema from "effect/Schema";
1415
import * as Stream from "effect/Stream";
1516

@@ -175,6 +176,230 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
175176
}),
176177
);
177178

179+
it.effect("reports a Grok session running only while the prompt is in flight", () =>
180+
Effect.gen(function* () {
181+
const threadId = ThreadId.make("grok-session-ready-after-prompt");
182+
const wrapperPath = yield* Effect.promise(() =>
183+
makeMockGrokWrapper({
184+
T3_ACP_EMIT_TOOL_CALLS: "1",
185+
}),
186+
);
187+
const adapter = yield* makeTestAdapter(wrapperPath);
188+
const requestOpened =
189+
yield* Deferred.make<Extract<ProviderRuntimeEvent, { type: "request.opened" }>>();
190+
const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
191+
event.type === "request.opened"
192+
? Deferred.succeed(requestOpened, event).pipe(Effect.ignore)
193+
: Effect.void,
194+
).pipe(Effect.forkChild);
195+
196+
yield* adapter.startSession({
197+
threadId,
198+
provider: ProviderDriverKind.make("grok"),
199+
cwd: process.cwd(),
200+
runtimeMode: "approval-required",
201+
modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" },
202+
});
203+
204+
const sendTurnFiber = yield* adapter
205+
.sendTurn({ threadId, input: "check lifecycle", attachments: [] })
206+
.pipe(Effect.forkChild);
207+
const requestOpenedEvent = yield* Deferred.await(requestOpened);
208+
209+
const runningSessions = yield* adapter.listSessions();
210+
const runningSession = runningSessions.find((session) => session.threadId === threadId);
211+
assert.equal(runningSession?.status, "running");
212+
assert.isDefined(runningSession?.activeTurnId);
213+
214+
yield* adapter.respondToRequest(
215+
threadId,
216+
ApprovalRequestId.make(String(requestOpenedEvent.requestId)),
217+
"accept",
218+
);
219+
yield* Fiber.join(sendTurnFiber);
220+
221+
const readySessions = yield* adapter.listSessions();
222+
const readySession = readySessions.find((session) => session.threadId === threadId);
223+
assert.equal(readySession?.status, "ready");
224+
assert.isUndefined(readySession?.activeTurnId);
225+
226+
yield* Fiber.interrupt(eventsFiber);
227+
yield* adapter.stopSession(threadId);
228+
}),
229+
);
230+
231+
it.effect("completes a Grok turn from xAI prompt completion when the prompt RPC hangs", () =>
232+
Effect.gen(function* () {
233+
const threadId = ThreadId.make("grok-xai-prompt-complete-fallback");
234+
const wrapperPath = yield* Effect.promise(() =>
235+
makeMockGrokWrapper({
236+
T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1",
237+
}),
238+
);
239+
const adapter = yield* makeTestAdapter(wrapperPath);
240+
241+
const runtimeEvents: ProviderRuntimeEvent[] = [];
242+
const turnCompleted = yield* Deferred.make<void>();
243+
const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
244+
Effect.sync(() => {
245+
runtimeEvents.push(event);
246+
}).pipe(
247+
Effect.andThen(
248+
event.type === "turn.completed"
249+
? Deferred.succeed(turnCompleted, undefined)
250+
: Effect.void,
251+
),
252+
),
253+
).pipe(Effect.forkChild);
254+
255+
yield* adapter.startSession({
256+
threadId,
257+
provider: ProviderDriverKind.make("grok"),
258+
cwd: process.cwd(),
259+
runtimeMode: "full-access",
260+
modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" },
261+
});
262+
263+
const sendTurnResult = yield* adapter.sendTurn({
264+
threadId,
265+
input: "exercise fallback",
266+
attachments: [],
267+
});
268+
269+
yield* Deferred.await(turnCompleted);
270+
const readySessions = yield* adapter.listSessions();
271+
const readySession = readySessions.find((session) => session.threadId === threadId);
272+
const turnCompletedEvent = runtimeEvents.find(
273+
(event): event is Extract<ProviderRuntimeEvent, { type: "turn.completed" }> =>
274+
event.type === "turn.completed",
275+
);
276+
const eventTypes = runtimeEvents.map((event) => event.type);
277+
278+
assert.equal(sendTurnResult.threadId, threadId);
279+
assert.include(eventTypes, "turn.completed");
280+
assert.equal(turnCompletedEvent?.payload.stopReason, "end_turn");
281+
assert.equal(readySession?.status, "ready");
282+
assert.isUndefined(readySession?.activeTurnId);
283+
284+
yield* Fiber.interrupt(runtimeEventsFiber);
285+
yield* adapter.stopSession(threadId);
286+
}),
287+
);
288+
289+
it.effect("settles the in-flight prompt before emitting completion", () =>
290+
Effect.gen(function* () {
291+
const threadId = ThreadId.make("grok-completion-before-next-turn");
292+
const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper());
293+
const adapter = yield* makeTestAdapter(wrapperPath);
294+
const completedCountRef = yield* Ref.make(0);
295+
const secondTurnCompleted = yield* Deferred.make<void>();
296+
297+
const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => {
298+
if (event.type !== "turn.completed" || String(event.threadId) !== String(threadId)) {
299+
return Effect.void;
300+
}
301+
302+
return Ref.modify(completedCountRef, (count) => {
303+
const nextCount = count + 1;
304+
return [nextCount, nextCount] as const;
305+
}).pipe(
306+
Effect.flatMap((count) => {
307+
if (count === 1) {
308+
return adapter
309+
.sendTurn({
310+
threadId,
311+
input: "second turn after completion",
312+
attachments: [],
313+
})
314+
.pipe(Effect.forkChild, Effect.asVoid);
315+
}
316+
if (count === 2) {
317+
return Deferred.succeed(secondTurnCompleted, undefined).pipe(Effect.asVoid);
318+
}
319+
return Effect.void;
320+
}),
321+
);
322+
}).pipe(Effect.forkChild);
323+
324+
yield* adapter.startSession({
325+
threadId,
326+
provider: ProviderDriverKind.make("grok"),
327+
cwd: process.cwd(),
328+
runtimeMode: "full-access",
329+
modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" },
330+
});
331+
332+
yield* adapter.sendTurn({
333+
threadId,
334+
input: "first turn",
335+
attachments: [],
336+
});
337+
yield* Deferred.await(secondTurnCompleted);
338+
339+
const completedCount = yield* Ref.get(completedCountRef);
340+
const readySessions = yield* adapter.listSessions();
341+
const readySession = readySessions.find((session) => session.threadId === threadId);
342+
343+
assert.equal(completedCount, 2);
344+
assert.equal(readySession?.status, "ready");
345+
assert.isUndefined(readySession?.activeTurnId);
346+
347+
yield* Fiber.interrupt(runtimeEventsFiber);
348+
yield* adapter.stopSession(threadId);
349+
}),
350+
);
351+
352+
it.effect("restores a Grok session to ready when the prompt RPC fails", () =>
353+
Effect.gen(function* () {
354+
const threadId = ThreadId.make("grok-prompt-failure-ready");
355+
const wrapperPath = yield* Effect.promise(() =>
356+
makeMockGrokWrapper({
357+
T3_ACP_FAIL_PROMPT: "1",
358+
}),
359+
);
360+
const adapter = yield* makeTestAdapter(wrapperPath);
361+
const runtimeEvents: ProviderRuntimeEvent[] = [];
362+
const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
363+
Effect.sync(() => {
364+
runtimeEvents.push(event);
365+
}),
366+
).pipe(Effect.forkChild);
367+
368+
yield* adapter.startSession({
369+
threadId,
370+
provider: ProviderDriverKind.make("grok"),
371+
cwd: process.cwd(),
372+
runtimeMode: "full-access",
373+
modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" },
374+
});
375+
376+
const error = yield* Effect.flip(
377+
adapter.sendTurn({
378+
threadId,
379+
input: "fail prompt",
380+
attachments: [],
381+
}),
382+
);
383+
const readySessions = yield* adapter.listSessions();
384+
const readySession = readySessions.find((session) => session.threadId === threadId);
385+
const failedTurnCompleted = runtimeEvents.find(
386+
(event) => event.type === "turn.completed" && event.threadId === threadId,
387+
);
388+
389+
assert.equal(error._tag, "ProviderAdapterRequestError");
390+
assert.equal(readySession?.status, "ready");
391+
assert.isUndefined(readySession?.activeTurnId);
392+
assert.equal(failedTurnCompleted?.type, "turn.completed");
393+
if (failedTurnCompleted?.type === "turn.completed") {
394+
assert.equal(failedTurnCompleted.payload.state, "failed");
395+
assert.isString(failedTurnCompleted.payload.errorMessage);
396+
}
397+
398+
yield* Fiber.interrupt(runtimeEventsFiber);
399+
yield* adapter.stopSession(threadId);
400+
}),
401+
);
402+
178403
it.effect("rejects startSession when provider mismatches", () =>
179404
Effect.gen(function* () {
180405
const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper());

0 commit comments

Comments
 (0)