Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts";
import {
ProviderRuntimeIngestionLive,
runtimeEventToActivities,
} from "./ProviderRuntimeIngestion.ts";
import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
Expand All @@ -66,6 +69,35 @@ const asMessageId = (value: string): MessageId => MessageId.make(value);
const asThreadId = (value: string): ThreadId => ThreadId.make(value);
const asTurnId = (value: string): TurnId => TurnId.make(value);

describe("runtimeEventToActivities", () => {
it("persists prompt suggestions as hidden turn-scoped composer metadata", () => {
const activities = runtimeEventToActivities({
type: "thread.metadata.updated",
eventId: asEventId("evt-prompt-suggestion"),
provider: ProviderDriverKind.make("claudeAgent"),
createdAt: "2026-08-11T00:00:00.000Z",
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-1"),
payload: { suggestedPrompt: "run the tests" },
});

expect(activities).toEqual([
{
id: asEventId("evt-prompt-suggestion"),
createdAt: "2026-08-11T00:00:00.000Z",
tone: "info",
kind: "prompt-suggestion.updated",
summary: "Prompt suggestion updated",
payload: {
suggestedPrompt: "run the tests",
timelineBypass: true,
},
turnId: asTurnId("turn-1"),
},
]);
});
});

type LegacyProviderRuntimeEvent = {
readonly type: string;
readonly eventId: EventId;
Expand Down
22 changes: 22 additions & 0 deletions apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,28 @@ export function runtimeEventToActivities(
: {};
})();
switch (event.type) {
case "thread.metadata.updated": {
if (!event.payload.suggestedPrompt) {
return [];
}
return [
{
id: event.eventId,
createdAt: event.createdAt,
tone: "info",
kind: "prompt-suggestion.updated",
summary: "Prompt suggestion updated",
payload: {
suggestedPrompt: event.payload.suggestedPrompt,
// Composer state only; never render this metadata in the work log.
timelineBypass: true,
},
turnId: toTurnId(event.turnId) ?? null,
...maybeSequence,
},
];
}

case "request.opened": {
if (event.payload.requestType === "tool_user_input") {
return [];
Expand Down
79 changes: 76 additions & 3 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,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 All @@ -380,6 +381,24 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("disables prompt suggestions from Claude provider settings", () => {
const harness = makeHarness({ claudeConfig: { promptSuggestions: false } });
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
runtimeMode: "full-access",
});

const createInput = harness.getLastCreateQueryInput();
assert.equal(createInput?.options.promptSuggestions, false);
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("loads Claude filesystem settings sources for SDK sessions", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down Expand Up @@ -978,6 +997,59 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("emits prompt suggestions for the completed turn", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
const runtimeEvents: Array<ProviderRuntimeEvent> = [];
const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
Effect.sync(() => runtimeEvents.push(event)),
).pipe(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: "result",
subtype: "success",
is_error: false,
errors: [],
session_id: "sdk-session-suggestion",
uuid: "result-suggestion",
} as unknown as SDKMessage);
harness.query.emit({
type: "prompt_suggestion",
suggestion: " run the tests ",
session_id: "sdk-session-suggestion",
uuid: "prompt-suggestion",
} as unknown as SDKMessage);
yield* Effect.yieldNow;
yield* Effect.yieldNow;

const suggestionEvent = runtimeEvents.find(
(event) =>
event.type === "thread.metadata.updated" && event.payload.suggestedPrompt !== undefined,
);
assert.equal(suggestionEvent?.type, "thread.metadata.updated");
if (suggestionEvent?.type === "thread.metadata.updated") {
assert.equal(String(suggestionEvent.turnId), String(turn.turnId));
assert.equal(suggestionEvent.payload.suggestedPrompt, "run the tests");
}
runtimeEventsFiber.interruptUnsafe();
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("does not emit turn.completed for a result with no active turn", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down Expand Up @@ -2157,9 +2229,10 @@ describe("ClaudeAdapterLive", () => {
runtimeMode: "full-access",
});

// Undeclared wire-only roster snapshot + every typed UX-internal
// subtype and top-level type consumed silently: none may surface as
// unknown-subtype warnings.
// Undeclared wire-only roster snapshot + typed UX-internal messages
// must not surface as unknown-subtype warnings. A prompt suggestion
// before the first turn is intentionally ignored because it has no
// turn to attach to.
for (const message of [
{
type: "system",
Expand Down
26 changes: 24 additions & 2 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3512,9 +3512,30 @@ 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":
case "prompt_suggestion": {
const suggestion = message.suggestion.trim();
const turnId = context.turnState?.turnId ?? context.turns.at(-1)?.id;
if (!suggestion || !turnId) {
return;
}
const stamp = yield* makeEventStamp();
yield* offerRuntimeEvent({
type: "thread.metadata.updated",
eventId: stamp.eventId,
provider: PROVIDER,
createdAt: stamp.createdAt,
threadId: context.session.threadId,
turnId,
payload: { suggestedPrompt: suggestion },
providerRefs: nativeProviderRefs(context),
raw: {
source: "claude.sdk.message",
method: "claude/prompt_suggestion",
payload: message,
},
});
return;
}
default: {
// Exhaustiveness guard (see handleSystemMessage): new SDK top-level
// message types fail typecheck here instead of warning at runtime.
Expand Down Expand Up @@ -4119,6 +4140,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
...(existingResumeSessionId ? { resume: existingResumeSessionId } : {}),
...(newSessionId ? { sessionId: newSessionId } : {}),
includePartialMessages: true,
promptSuggestions: claudeSettings.promptSuggestions,
canUseTool,
env: claudeEnvironment,
additionalDirectories,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ const makeClaudeConfig = (overrides: Partial<ClaudeSettings>): ClaudeSettings =>
homePath: "",
customModels: [],
launchArgs: "",
promptSuggestions: true,
...overrides,
});

Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
homePath: "",
customModels: ["claude-custom"],
launchArgs: "",
promptSuggestions: true,
});
assert.deepEqual(
next.textGenerationModelSelection,
Expand Down Expand Up @@ -522,6 +523,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
homePath: "",
customModels: [],
launchArgs: "",
promptSuggestions: true,
});
assert.deepEqual(next.providers.opencode, {
enabled: true,
Expand Down
22 changes: 21 additions & 1 deletion apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
detectComposerTrigger,
expandCollapsedComposerCursor,
replaceTextRange,
shouldAcceptPromptSuggestionOnTab,
shouldSubmitComposerOnEnter,
} from "../../composer-logic";
import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic";
Expand Down Expand Up @@ -226,6 +227,7 @@ import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"
import { searchProviderSkills } from "../../providerSkillSearch";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import type { ReviewCommentContext } from "../../reviewCommentContext";
import { deriveLatestPromptSuggestion } from "../../lib/promptSuggestion";

const runtimeModeConfig: Record<
RuntimeMode,
Expand Down Expand Up @@ -928,6 +930,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
}
return formatProviderDisplayName(activeThreadModelSelection.instanceId);
}, [providerStatuses, activeThreadModelSelection]);
const latestPromptSuggestion = useMemo(
() =>
selectedProvider === "claudeAgent" && phase === "ready"
? deriveLatestPromptSuggestion(activeThreadActivities ?? [], activeThread?.latestTurn)
: null,
[activeThread?.latestTurn, activeThreadActivities, phase, selectedProvider],
);

// ------------------------------------------------------------------
// Composer-local state
Expand Down Expand Up @@ -1891,6 +1900,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
return true;
}
}
if (
shouldAcceptPromptSuggestionOnTab({
key,
shiftKey: event.shiftKey,
prompt,
suggestedPrompt: latestPromptSuggestion,
})
) {
return applyPromptReplacement(0, 0, latestPromptSuggestion ?? "");
}
if (
key === "Enter" &&
shouldSubmitComposerOnEnter({ isMobileViewport, shiftKey: event.shiftKey })
Expand Down Expand Up @@ -3051,7 +3070,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
? "Enable a provider in Settings to send a message"
: phase === "disconnected"
? "Ask for follow-up changes or attach images"
: "Ask anything, @tag files/folders, $use skills, or / for commands"
: (latestPromptSuggestion ??
"Ask anything, @tag files/folders, $use skills, or / for commands")
}
disabled={isConnecting || isComposerApprovalState || projectSelectionRequired}
/>
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/components/settings/ProviderSettingsForm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ describe("ProviderSettingsForm helpers", () => {
});
});

it("shows Claude prompt suggestions as an on-by-default switch", () => {
const claude = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("claudeAgent")];
expect(claude).toBeDefined();

const promptSuggestions = deriveProviderSettingsFields(claude!).find(
(field) => field.key === "promptSuggestions",
);

expect(promptSuggestions).toMatchObject({
label: "Prompt suggestions",
control: "switch",
defaultBooleanValue: true,
});
});

it("preserves unknown config keys while omitting empty configurable fields", () => {
const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")];
expect(opencode).toBeDefined();
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/composer-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
isCollapsedCursorAdjacentToInlineToken,
parseStandaloneComposerSlashCommand,
replaceTextRange,
shouldAcceptPromptSuggestionOnTab,
shouldSubmitComposerOnEnter,
} from "./composer-logic";
import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";
Expand All @@ -26,6 +27,38 @@ describe("shouldSubmitComposerOnEnter", () => {
});
});

describe("shouldAcceptPromptSuggestionOnTab", () => {
it("accepts a suggestion with plain Tab in an empty composer", () => {
expect(
shouldAcceptPromptSuggestionOnTab({
key: "Tab",
shiftKey: false,
prompt: "",
suggestedPrompt: "run the tests",
}),
).toBe(true);
});

it("does not replace user input or handle Shift+Tab", () => {
expect(
shouldAcceptPromptSuggestionOnTab({
key: "Tab",
shiftKey: false,
prompt: "already typing",
suggestedPrompt: "run the tests",
}),
).toBe(false);
expect(
shouldAcceptPromptSuggestionOnTab({
key: "Tab",
shiftKey: true,
prompt: "",
suggestedPrompt: "run the tests",
}),
).toBe(false);
});
});

describe("detectComposerTrigger", () => {
it("detects @path trigger at cursor", () => {
const text = "Please check @src/com";
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/composer-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ export function shouldSubmitComposerOnEnter(input: {
return !input.isMobileViewport && !input.shiftKey;
}

export function shouldAcceptPromptSuggestionOnTab(input: {
key: string;
shiftKey: boolean;
prompt: string;
suggestedPrompt: string | null;
}): boolean {
return (
input.key === "Tab" &&
!input.shiftKey &&
input.prompt.length === 0 &&
input.suggestedPrompt !== null
);
}

const isInlineTokenSegment = (
segment:
| { type: "text"; text: string }
Expand Down
Loading
Loading