Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit a52ddb0

Browse files
refactor(core): extract permission and composer semantics (#3634)
1 parent afb8fd5 commit a52ddb0

16 files changed

Lines changed: 498 additions & 86 deletions

packages/core/src/sessions/permissionResponse.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,62 @@ import type { PermissionRequest } from "@posthog/shared";
22
import { describe, expect, it } from "vitest";
33
import {
44
formatPermissionAnswerPrompt,
5+
getPermissionOptionMeta,
56
isOtherPermissionOption,
7+
isPermissionApproval,
8+
isPermissionRejection,
9+
permissionOptionUsesCustomInput,
610
planPermissionResponse,
11+
resolveInitialPlanApprovalOption,
12+
selectPlanPermissionOptions,
713
} from "./permissionResponse";
814

15+
describe("permission option presentation", () => {
16+
const approveOnce = {
17+
optionId: "default",
18+
name: "Approve",
19+
kind: "allow_once" as const,
20+
};
21+
const approveAuto = {
22+
optionId: "auto",
23+
name: "Approve automatically",
24+
kind: "allow_always" as const,
25+
};
26+
const reject = {
27+
optionId: "reject_with_feedback",
28+
name: "Reject",
29+
kind: "reject_once" as const,
30+
_meta: { customInput: true, description: "Explain why" },
31+
};
32+
33+
it("classifies approval, rejection, and custom-input options", () => {
34+
expect(isPermissionApproval(approveOnce)).toBe(true);
35+
expect(isPermissionRejection(reject)).toBe(true);
36+
expect(permissionOptionUsesCustomInput(reject)).toBe(true);
37+
expect(getPermissionOptionMeta(reject)).toEqual({
38+
customInput: true,
39+
description: "Explain why",
40+
});
41+
});
42+
43+
it("selects plan options and prefers a feedback rejection", () => {
44+
expect(selectPlanPermissionOptions([approveOnce, reject])).toEqual({
45+
approvals: [approveOnce],
46+
rejection: reject,
47+
});
48+
});
49+
50+
it.each([
51+
["default", "default"],
52+
[null, "auto"],
53+
["missing", "auto"],
54+
])("resolves preferred approval %s", (preferred, expected) => {
55+
expect(
56+
resolveInitialPlanApprovalOption([approveOnce, approveAuto], preferred),
57+
).toBe(expected);
58+
});
59+
});
60+
961
function makePermission(
1062
options: Array<{
1163
optionId: string;

packages/core/src/sessions/permissionResponse.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,72 @@
11
import type { PermissionRequest } from "@posthog/shared";
22

3+
export type PermissionOption = PermissionRequest["options"][number];
4+
5+
export function getPermissionOptionMeta(option: PermissionOption): {
6+
customInput: boolean;
7+
description?: string;
8+
} {
9+
const meta = option._meta as
10+
| { customInput?: boolean; description?: string }
11+
| null
12+
| undefined;
13+
return {
14+
customInput: meta?.customInput === true,
15+
...(meta?.description ? { description: meta.description } : {}),
16+
};
17+
}
18+
19+
export function isPermissionApproval(option: PermissionOption): boolean {
20+
return option.kind === "allow_once" || option.kind === "allow_always";
21+
}
22+
23+
export function isPermissionRejection(option: PermissionOption): boolean {
24+
return (
25+
option.kind === "reject_once" ||
26+
option.kind === "reject_always" ||
27+
option.optionId.includes("reject")
28+
);
29+
}
30+
31+
export function permissionOptionUsesCustomInput(
32+
option: PermissionOption,
33+
): boolean {
34+
return (
35+
isOtherPermissionOption(option.optionId) ||
36+
getPermissionOptionMeta(option).customInput
37+
);
38+
}
39+
40+
export function selectPlanPermissionOptions(options: PermissionOption[]): {
41+
approvals: PermissionOption[];
42+
rejection: PermissionOption | null;
43+
} {
44+
const approvals = options.filter(isPermissionApproval);
45+
const rejections = options.filter(isPermissionRejection);
46+
return {
47+
approvals,
48+
rejection:
49+
rejections.find(permissionOptionUsesCustomInput) ?? rejections[0] ?? null,
50+
};
51+
}
52+
53+
export function resolveInitialPlanApprovalOption(
54+
approvals: PermissionOption[],
55+
preferredOptionId?: string | null,
56+
): string | undefined {
57+
const has = (optionId: string): boolean =>
58+
approvals.some((option) => option.optionId === optionId);
59+
return (
60+
(preferredOptionId && has(preferredOptionId)
61+
? preferredOptionId
62+
: undefined) ??
63+
(has("auto") ? "auto" : undefined) ??
64+
approvals.find((option) => option.optionId === "default")?.optionId ??
65+
approvals.find((option) => option.kind === "allow_once")?.optionId ??
66+
approvals[0]?.optionId
67+
);
68+
}
69+
370
const OTHER_OPTION_ID = "_other";
471
const OTHER_OPTION_ID_ALT = "other";
572

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, it } from "vitest";
2+
import { extractPlanText } from "./planApprovalPresentation";
3+
4+
describe("extractPlanText", () => {
5+
it.each([
6+
[{ rawInput: { plan: "Raw plan" } }, "Raw plan"],
7+
[{ content: [{ text: "Direct content" }] }, "Direct content"],
8+
[
9+
{
10+
content: [
11+
{ type: "content", content: { type: "text", text: "Nested" } },
12+
],
13+
},
14+
"Nested",
15+
],
16+
[{ rawInput: {}, content: [] }, null],
17+
])("extracts plan presentation from %o", (toolCall, expected) => {
18+
expect(extractPlanText(toolCall)).toBe(expected);
19+
});
20+
21+
it("prefers streamed content over stale raw input", () => {
22+
expect(
23+
extractPlanText({
24+
rawInput: { plan: "Canonical" },
25+
content: [{ text: "Rendered" }],
26+
}),
27+
).toBe("Rendered");
28+
});
29+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
function extractTextContent(item: unknown): string | null {
2+
if (!item || typeof item !== "object") return null;
3+
const record = item as Record<string, unknown>;
4+
if (typeof record.text === "string") return record.text;
5+
6+
if (!record.content || typeof record.content !== "object") return null;
7+
const content = record.content as Record<string, unknown>;
8+
return typeof content.text === "string" ? content.text : null;
9+
}
10+
11+
export function extractPlanText(toolCall: {
12+
rawInput?: { plan?: unknown } | null;
13+
content?: readonly unknown[] | null;
14+
}): string | null {
15+
for (const item of toolCall.content ?? []) {
16+
const text = extractTextContent(item);
17+
if (text?.trim()) return text;
18+
}
19+
20+
const rawPlan = toolCall.rawInput?.plan;
21+
if (typeof rawPlan === "string" && rawPlan.trim()) return rawPlan;
22+
return null;
23+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveComposerPrimaryAction } from "./composerControls";
3+
4+
describe("resolveComposerPrimaryAction", () => {
5+
it.each([
6+
[{ hasContent: true }, "send"],
7+
[{ canStop: true }, "stop"],
8+
[{ canStop: true, hasContent: true }, "send"],
9+
[{ canStop: true, hasContent: true, allowSendWhileRunning: false }, "stop"],
10+
[{ isRecording: true }, "mic-stop"],
11+
[{}, "mic"],
12+
[{ disabled: true, hasContent: true }, "disabled"],
13+
[{ isTranscribing: true }, "disabled"],
14+
])("derives %s", (overrides, expected) => {
15+
expect(
16+
resolveComposerPrimaryAction({
17+
hasContent: false,
18+
disabled: false,
19+
isRecording: false,
20+
isTranscribing: false,
21+
canStop: false,
22+
allowSendWhileRunning: true,
23+
...overrides,
24+
}),
25+
).toBe(expected);
26+
});
27+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import {
2+
type Adapter,
3+
type CloudTaskConfigOption,
4+
DEFAULT_REASONING_EFFORT,
5+
isRestrictedModelOption,
6+
isSupportedReasoningEffort,
7+
type SupportedReasoningEffort,
8+
} from "@posthog/shared";
9+
10+
export interface ComposerModelOption {
11+
value: string;
12+
label: string;
13+
description?: string;
14+
disabled: boolean;
15+
}
16+
17+
export function getModelConfigOption(
18+
configOptions: readonly CloudTaskConfigOption[],
19+
): CloudTaskConfigOption {
20+
const option = configOptions.find((item) => item.category === "model");
21+
if (!option) throw new Error("Cloud task model configuration is unavailable");
22+
return option;
23+
}
24+
25+
export function getComposerModelOptions(
26+
modelOption: CloudTaskConfigOption,
27+
): ComposerModelOption[] {
28+
return modelOption.options.map((option) => ({
29+
value: option.value,
30+
label: option.name,
31+
description: option.description,
32+
disabled: isRestrictedModelOption(option._meta),
33+
}));
34+
}
35+
36+
export function getConfigOptionLabel(
37+
options: ReadonlyArray<{ value: string; name: string }>,
38+
value: string | undefined,
39+
): string | undefined {
40+
return options.find((option) => option.value === value)?.name ?? value;
41+
}
42+
43+
export function resolveAvailableModel(
44+
modelOption: CloudTaskConfigOption,
45+
value: string,
46+
): string {
47+
const selected = modelOption.options.find((option) => option.value === value);
48+
return selected && !isRestrictedModelOption(selected._meta)
49+
? value
50+
: modelOption.currentValue;
51+
}
52+
53+
export function resolveComposerModelChange({
54+
adapter,
55+
modelOption,
56+
requestedModel,
57+
reasoning,
58+
}: {
59+
adapter: Adapter;
60+
modelOption: CloudTaskConfigOption;
61+
requestedModel: string;
62+
reasoning: SupportedReasoningEffort;
63+
}): { model: string; reasoning: SupportedReasoningEffort } {
64+
const model = resolveAvailableModel(modelOption, requestedModel);
65+
return {
66+
model,
67+
reasoning: isSupportedReasoningEffort(adapter, model, reasoning)
68+
? reasoning
69+
: DEFAULT_REASONING_EFFORT,
70+
};
71+
}
72+
73+
export type ComposerPrimaryAction =
74+
| "send"
75+
| "stop"
76+
| "mic"
77+
| "mic-stop"
78+
| "disabled";
79+
80+
export function resolveComposerPrimaryAction({
81+
hasContent,
82+
disabled,
83+
isRecording,
84+
isTranscribing,
85+
canStop,
86+
allowSendWhileRunning,
87+
}: {
88+
hasContent: boolean;
89+
disabled: boolean;
90+
isRecording: boolean;
91+
isTranscribing: boolean;
92+
canStop: boolean;
93+
allowSendWhileRunning: boolean;
94+
}): ComposerPrimaryAction {
95+
if (disabled || isTranscribing) return "disabled";
96+
if (canStop && (!allowSendWhileRunning || !hasContent)) return "stop";
97+
if (hasContent && !isRecording) return "send";
98+
return isRecording ? "mic-stop" : "mic";
99+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {
2+
type Adapter,
3+
type CloudTaskConfigOption,
4+
DEFAULT_GATEWAY_MODEL,
5+
restrictedModelMeta,
6+
type SupportedReasoningEffort,
7+
} from "@posthog/shared";
8+
import { expect, it } from "vitest";
9+
import { resolveCloudComposerModelChange } from "./composerModelPolicy";
10+
11+
const modelOption: CloudTaskConfigOption = {
12+
id: "model",
13+
name: "Model",
14+
type: "select",
15+
currentValue: DEFAULT_GATEWAY_MODEL,
16+
options: [
17+
{ value: DEFAULT_GATEWAY_MODEL, name: "Claude" },
18+
{ value: "restricted", name: "Restricted", _meta: restrictedModelMeta() },
19+
{ value: "gpt-5.3-codex", name: "Codex" },
20+
],
21+
category: "model",
22+
description: "Choose a model",
23+
};
24+
25+
it.each([
26+
["claude", DEFAULT_GATEWAY_MODEL, "high", DEFAULT_GATEWAY_MODEL, "high"],
27+
["claude", "restricted", "high", DEFAULT_GATEWAY_MODEL, "high"],
28+
["claude", "missing", "high", DEFAULT_GATEWAY_MODEL, "high"],
29+
["codex", "gpt-5.3-codex", "xhigh", "gpt-5.3-codex", "high"],
30+
] as const)(
31+
"resolves %s model %s with %s reasoning",
32+
(adapter, requestedModel, reasoning, expectedModel, expectedReasoning) => {
33+
expect(
34+
resolveCloudComposerModelChange({
35+
adapter: adapter as Adapter,
36+
modelOption,
37+
requestedModel,
38+
reasoning: reasoning as SupportedReasoningEffort,
39+
}),
40+
).toEqual({ model: expectedModel, reasoning: expectedReasoning });
41+
},
42+
);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import {
2+
type Adapter,
3+
type CloudTaskConfigOption,
4+
DEFAULT_REASONING_EFFORT,
5+
isRestrictedModelOption,
6+
isSupportedReasoningEffort,
7+
type SupportedReasoningEffort,
8+
} from "@posthog/shared";
9+
10+
export function resolveCloudComposerModelChange({
11+
adapter,
12+
modelOption,
13+
requestedModel,
14+
reasoning,
15+
}: {
16+
adapter: Adapter;
17+
modelOption: CloudTaskConfigOption;
18+
requestedModel: string;
19+
reasoning: SupportedReasoningEffort;
20+
}): { model: string; reasoning: SupportedReasoningEffort } {
21+
const selected = modelOption.options.find(
22+
(option) => option.value === requestedModel,
23+
);
24+
const model =
25+
selected && !isRestrictedModelOption(selected._meta)
26+
? requestedModel
27+
: modelOption.currentValue;
28+
29+
return {
30+
model,
31+
reasoning: isSupportedReasoningEffort(adapter, model, reasoning)
32+
? reasoning
33+
: DEFAULT_REASONING_EFFORT,
34+
};
35+
}

0 commit comments

Comments
 (0)