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
2 changes: 2 additions & 0 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,8 @@ function deriveWorkLogEntries(
if (activity.kind === "task.updated" && !isTerminalBypassUpdate(activity)) continue;
if (activity.kind === "tool.progress") continue;
if (activity.kind === "context-window.updated") continue;
// Composer prompt suggestions have no mobile surface yet.
if (activity.kind === "prompt-suggestion") continue;
if (activity.summary === "Checkpoint captured") continue;
if (isNoContentRuntimeWarning(activity)) continue;
if (isPlanBoundaryToolActivity(activity)) continue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2918,6 +2918,16 @@ describe("ProviderRuntimeIngestion", () => {
},
});

harness.emit({
type: "turn.prompt-suggestion",
eventId: asEventId("evt-turn-prompt-suggestion"),
provider: ProviderDriverKind.make("codex"),
createdAt: now,
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-p1"),
payload: { suggestion: "Run the tests again" },
});

harness.emit({
type: "turn.diff.updated",
eventId: asEventId("evt-turn-diff-updated"),
Expand All @@ -2944,6 +2954,9 @@ describe("ProviderRuntimeIngestion", () => {
entry.activities.some(
(activity: ProviderRuntimeTestActivity) => activity.kind === "runtime.warning",
) &&
entry.activities.some(
(activity: ProviderRuntimeTestActivity) => activity.kind === "prompt-suggestion",
) &&
entry.checkpoints.some(
(checkpoint: ProviderRuntimeTestCheckpoint) => checkpoint.turnId === "turn-p1",
),
Expand All @@ -2961,6 +2974,13 @@ describe("ProviderRuntimeIngestion", () => {
expect(planActivity?.kind).toBe("turn.plan.updated");
expect(Array.isArray(planPayload?.plan)).toBe(true);

const suggestionActivity = thread.activities.find(
(activity: ProviderRuntimeTestActivity) => activity.id === "evt-turn-prompt-suggestion",
);
expect(suggestionActivity?.kind).toBe("prompt-suggestion");
expect(suggestionActivity?.turnId).toBe("turn-p1");
expect(suggestionActivity?.payload).toEqual({ suggestion: "Run the tests again" });

const toolUpdate = thread.activities.find(
(activity: ProviderRuntimeTestActivity) => activity.id === "evt-item-updated",
);
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,23 @@ export function runtimeEventToActivities(
];
}

case "turn.prompt-suggestion": {
// Predicted next user prompt for the turn that just completed. Clients
// read the newest row for the latest turn; the work log hides it.
return [
{
id: event.eventId,
createdAt: event.createdAt,
tone: "info",
kind: "prompt-suggestion",
summary: "Prompt suggestion",
payload: { suggestion: event.payload.suggestion },
turnId: toTurnId(event.turnId) ?? null,
...maybeSequence,
},
];
}

case "user-input.requested": {
return [
{
Expand Down
132 changes: 132 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ describe("ClaudeAdapterLive", () => {
assert.deepEqual(createInput?.options.settingSources, ["user", "project", "local"]);
assert.equal(createInput?.options.permissionMode, "bypassPermissions");
assert.equal(createInput?.options.allowDangerouslySkipPermissions, true);
assert.equal(createInput?.options.promptSuggestions, true);
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
Expand Down Expand Up @@ -847,6 +848,132 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("emits turn.prompt-suggestion for the turn that just completed", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;

const runtimeEventsFiber = yield* Stream.takeUntil(
adapter.streamEvents,
(event) => event.type === "turn.prompt-suggestion",
).pipe(Stream.runCollect, Effect.forkChild);

const session = yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
runtimeMode: "full-access",
});
const turn = yield* adapter.sendTurn({
threadId: session.threadId,
input: "hello",
attachments: [],
});

harness.query.emit({
type: "assistant",
session_id: "sdk-session-1",
uuid: "assistant-1",
parent_tool_use_id: null,
message: {
id: "assistant-message-1",
content: [{ type: "text", text: "Hi" }],
},
} as unknown as SDKMessage);
harness.query.emit({
type: "result",
subtype: "success",
is_error: false,
errors: [],
session_id: "sdk-session-1",
uuid: "result-1",
} as unknown as SDKMessage);
// The SDK delivers the suggestion after `result`, once the turn is closed.
harness.query.emit({
type: "prompt_suggestion",
suggestion: " Now run the tests ",
session_id: "sdk-session-1",
uuid: "ps-1",
} as unknown as SDKMessage);

const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
const completedIndex = runtimeEvents.findIndex((event) => event.type === "turn.completed");
const suggestion = runtimeEvents.at(-1);
assert.notEqual(completedIndex, -1);
assert.equal(suggestion?.type, "turn.prompt-suggestion");
if (suggestion?.type === "turn.prompt-suggestion") {
assert.equal(suggestion.payload.suggestion, "Now run the tests");
assert.equal(String(suggestion.turnId), String(turn.turnId));
}
assert.isBelow(completedIndex, runtimeEvents.length - 1);
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect(
"keeps a trailing suggestion on its own turn when the next turn already started",
() => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;

const session = yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
runtimeMode: "full-access",
});
const firstTurn = yield* adapter.sendTurn({
threadId: session.threadId,
input: "hello",
attachments: [],
});
const firstTurnCompleted = yield* Stream.takeUntil(
adapter.streamEvents,
(event) => event.type === "turn.completed",
).pipe(Stream.runDrain, Effect.forkChild);
harness.query.emit({
type: "result",
subtype: "success",
is_error: false,
errors: [],
session_id: "sdk-session-1",
uuid: "result-1",
} as unknown as SDKMessage);
yield* Fiber.join(firstTurnCompleted);

// The user sends again before the SDK delivers the first turn's
// suggestion; it must not be re-homed onto the new turn.
const secondTurn = yield* adapter.sendTurn({
threadId: session.threadId,
input: "and again",
attachments: [],
});
const runtimeEventsFiber = yield* Stream.takeUntil(
adapter.streamEvents,
(event) => event.type === "turn.prompt-suggestion",
).pipe(Stream.runCollect, Effect.forkChild);
harness.query.emit({
type: "prompt_suggestion",
suggestion: "Now run the tests",
session_id: "sdk-session-1",
uuid: "ps-1",
} as unknown as SDKMessage);

const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
const suggestion = runtimeEvents.at(-1);
assert.equal(suggestion?.type, "turn.prompt-suggestion");
if (suggestion?.type === "turn.prompt-suggestion") {
assert.equal(String(suggestion.turnId), String(firstTurn.turnId));
assert.notEqual(String(suggestion.turnId), String(secondTurn.turnId));
}
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
},
);

it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down Expand Up @@ -2546,6 +2673,11 @@ describe("ClaudeAdapterLive", () => {
yield* Effect.yieldNow;

const warnings = runtimeEvents.filter((event) => event.type === "runtime.warning");
// A suggestion with no turn to attach to is dropped, not surfaced.
assert.equal(
runtimeEvents.some((event) => event.type === "turn.prompt-suggestion"),
false,
);
// Exactly one warning: the high-priority notification. Nothing else.
assert.deepEqual(
warnings.map((event) => event.payload.message),
Expand Down
49 changes: 48 additions & 1 deletion apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,12 @@ interface ClaudeSessionContext {
/** Task ids that have started and not yet reached a terminal state. */
readonly liveTaskIds: Set<string>;
turnState: ClaudeTurnState | undefined;
/**
* Turn id of the most recently completed turn. `prompt_suggestion` arrives
* after `result` (turnState is already cleared), so the suggestion is
* attributed to this turn.
*/
lastCompletedTurnId: TurnId | undefined;
lastKnownContextWindow: number | undefined;
lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined;
lastKnownTotalProcessedTokens: number | undefined;
Expand Down Expand Up @@ -2418,6 +2424,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
});

const updatedAt = yield* nowIso;
context.lastCompletedTurnId = turnState.turnId;
context.turnState = undefined;
context.session = {
...context.session,
Expand All @@ -2429,6 +2436,40 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
yield* updateResumeCursor(context);
});

const handlePromptSuggestion = Effect.fn("handlePromptSuggestion")(function* (
context: ClaudeSessionContext,
message: Extract<SDKMessage, { type: "prompt_suggestion" }>,
) {
const suggestion = message.suggestion.trim();
if (suggestion.length === 0) {
return;
}
// The SDK emits the suggestion after `result`, i.e. after turn.completed
// cleared turnState. Attribute it to the turn that just finished, never to
// a turn the user may already have started meanwhile: that turn's own
// suggestion (if any) follows its own result.
const turnId = context.lastCompletedTurnId;
if (!turnId) {
return;
}
const stamp = yield* makeEventStamp();
yield* offerRuntimeEvent({
type: "turn.prompt-suggestion",
eventId: stamp.eventId,
provider: PROVIDER,
createdAt: stamp.createdAt,
threadId: context.session.threadId,
turnId,
payload: { suggestion },
providerRefs: nativeProviderRefs(context),
raw: {
source: "claude.sdk.message" as const,
method: "claude/prompt_suggestion",
payload: message,
},
});
});

const handleStreamEvent = Effect.fn("handleStreamEvent")(function* (
context: ClaudeSessionContext,
message: SDKMessage,
Expand Down Expand Up @@ -3609,8 +3650,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
case "rate_limit_event":
yield* handleSdkTelemetryMessage(context, message);
return;
// Composer prompt suggestions have no T3 surface; consumed deliberately.
case "prompt_suggestion":
yield* handlePromptSuggestion(context, message);
return;
default: {
// Exhaustiveness guard (see handleSystemMessage): new SDK top-level
Expand Down Expand Up @@ -4327,6 +4368,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
...(existingResumeSessionId ? { resume: existingResumeSessionId } : {}),
...(newSessionId ? { sessionId: newSessionId } : {}),
includePartialMessages: true,
// Opt in to the SDK's predicted next prompt (one `prompt_suggestion`
// after each turn's `result`). The user's own Claude settings still
// apply: `promptSuggestionEnabled: false` in settings.json or
// CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false suppresses them.
promptSuggestions: true,
canUseTool,
onUserDialog,
supportedDialogKinds: ["resume_return"],
Expand Down Expand Up @@ -4429,6 +4475,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
turnState: undefined,
lastKnownContextWindow: initialContextWindow,
lastKnownTokenUsage: undefined,
lastCompletedTurnId: undefined,
lastKnownTotalProcessedTokens: undefined,
lastAssistantUuid: resumeState?.resumeSessionAt,
lastThreadStartedId: undefined,
Expand Down
25 changes: 23 additions & 2 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import {
type TerminalContextDraft,
} from "~/lib/terminalContext";
import { cn, isMacPlatform } from "~/lib/utils";
import { Kbd } from "~/components/ui/kbd";
import { basenameOfPath } from "~/pierre-icons";
import {
COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME,
Expand Down Expand Up @@ -884,6 +885,11 @@ interface ComposerPromptEditorProps {
skills: ReadonlyArray<ServerProviderSkill>;
disabled: boolean;
placeholder: string;
/**
* Provider-predicted next prompt. Rendered as ghost text in place of the
* placeholder while the editor is empty; the parent accepts it on Tab.
*/
promptSuggestion?: string | null;
className?: string;
onRemoveTerminalContext: (contextId: string) => void;
onChange: (
Expand Down Expand Up @@ -1533,6 +1539,7 @@ function ComposerPromptEditorInner({
skills,
disabled,
placeholder,
promptSuggestion,
className,
onRemoveTerminalContext,
onChange,
Expand Down Expand Up @@ -1758,13 +1765,25 @@ function ComposerPromptEditorInner({
className,
)}
data-testid="composer-editor"
aria-placeholder={placeholder}
aria-placeholder={
promptSuggestion
? `Suggested: ${promptSuggestion}. Press Tab to accept.`
: placeholder
}
placeholder={<span />}
onPaste={onPaste}
/>
}
placeholder={
terminalContexts.length > 0 ? null : (
terminalContexts.length > 0 ? null : promptSuggestion ? (
<div
className="pointer-events-none absolute inset-0 flex items-baseline gap-2 leading-relaxed text-placeholder"
data-testid="composer-prompt-suggestion"
>
<span className="min-w-0 truncate">{promptSuggestion}</span>
<Kbd className="shrink-0">Tab</Kbd>
</div>
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
) : (
<div className="pointer-events-none absolute inset-0 leading-relaxed text-placeholder">
{placeholder}
</div>
Expand Down Expand Up @@ -1794,6 +1813,7 @@ export function ComposerPromptEditor({
skills,
disabled,
placeholder,
promptSuggestion,
className,
onRemoveTerminalContext,
onChange,
Expand Down Expand Up @@ -1838,6 +1858,7 @@ export function ComposerPromptEditor({
editorRef={editorRef}
{...(onCommandKeyDown ? { onCommandKeyDown } : {})}
{...(className ? { className } : {})}
{...(promptSuggestion ? { promptSuggestion } : {})}
/>
</LexicalComposer>
);
Expand Down
Loading
Loading