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

Commit dd72940

Browse files
authored
Fix always-on skill recovery
Generated-By: PostHog Code Task-Id: 73390913-7191-4450-9fd3-31dd12879508
1 parent 621d953 commit dd72940

10 files changed

Lines changed: 231 additions & 47 deletions

File tree

packages/agent/src/server/agent-server.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2204,14 +2204,13 @@ describe("AgentServer HTTP Mode", () => {
22042204
)?.text;
22052205

22062206
expect(sentText).toBe("/local-test-skill with context");
2207-
expect(sentMeta?.localSkillContext).toContain(
2208-
'local skill "/local-test-skill"',
2209-
);
22102207
expect(sentMeta?.localSkillContext).toContain("LOCAL_SKILL_MARKER");
22112208
expect(sentMeta?.localSkillContext).toContain(
22122209
"always-on skills apply for the entire session",
22132210
);
2214-
expect(sentMeta?.localSkillContext).toContain("with context");
2211+
expect(
2212+
String(sentMeta?.localSkillContext).match(/LOCAL_SKILL_MARKER/g),
2213+
).toHaveLength(1);
22152214
expect(sentMeta?.localSkillName).toBe("local-test-skill");
22162215
}, 20000);
22172216

packages/agent/src/server/agent-server.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2734,6 +2734,14 @@ export class AgentServer {
27342734
: null;
27352735

27362736
if (invocation) {
2737+
if (
2738+
alwaysOnSkills.some((skill) => skill.skillName === invocation.skillName)
2739+
) {
2740+
return {
2741+
skillName: invocation.skillName,
2742+
context: alwaysOnContext ?? "",
2743+
};
2744+
}
27372745
const hasMatchingArtifact = artifacts.some(
27382746
(artifact) =>
27392747
artifact.type === "skill_bundle" &&
@@ -2768,7 +2776,7 @@ export class AgentServer {
27682776
.join("\n");
27692777
const attachedContext = this.buildAttachedSkillsPromptContext(
27702778
runId,
2771-
artifacts,
2779+
artifacts.filter((artifact) => artifact.metadata?.always_on !== true),
27722780
messageText,
27732781
);
27742782
if (!alwaysOnContext) return attachedContext;

packages/core/src/sessions/sessionService.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,14 @@ export interface SessionServiceDeps {
348348
options: SessionConfigOption[],
349349
) => void;
350350
removePersistedConfigOptions: (taskRunId: string) => void;
351+
getPersistedAlwaysOnSkillInstructions?: (
352+
taskRunId: string,
353+
) => string | undefined;
354+
setPersistedAlwaysOnSkillInstructions?: (
355+
taskRunId: string,
356+
instructions: string,
357+
) => void;
358+
removePersistedAlwaysOnSkillInstructions?: (taskRunId: string) => void;
351359
adapterStore: {
352360
getAdapter(taskRunId: string): Adapter | undefined;
353361
setAdapter(taskRunId: string, adapter: Adapter): void;
@@ -1949,6 +1957,10 @@ export class SessionService {
19491957
const persistedConfigOptions = this.d.getPersistedConfigOptions(taskRunId);
19501958

19511959
const previous = this.d.store.getSessions()[taskRunId];
1960+
const resolvedAlwaysOnSkillInstructions =
1961+
alwaysOnSkillInstructions ??
1962+
previous?.alwaysOnSkillInstructions ??
1963+
this.d.getPersistedAlwaysOnSkillInstructions?.(taskRunId);
19521964

19531965
const session = createBaseSession(taskRunId, taskId, taskTitle);
19541966
// Repainting from the log must not blank a transcript we already hold:
@@ -2019,7 +2031,7 @@ export class SessionService {
20192031
this.d.settings;
20202032
const effectiveCustomInstructions = [
20212033
customInstructions,
2022-
alwaysOnSkillInstructions ?? previous?.alwaysOnSkillInstructions,
2034+
resolvedAlwaysOnSkillInstructions,
20232035
]
20242036
.filter((value): value is string => Boolean(value))
20252037
.join("\n\n");
@@ -2040,8 +2052,7 @@ export class SessionService {
20402052
});
20412053

20422054
if (result) {
2043-
session.alwaysOnSkillInstructions =
2044-
alwaysOnSkillInstructions ?? previous?.alwaysOnSkillInstructions;
2055+
session.alwaysOnSkillInstructions = resolvedAlwaysOnSkillInstructions;
20452056
const liveConfigOptions = result.configOptions as
20462057
| SessionConfigOption[]
20472058
| undefined;
@@ -2166,6 +2177,7 @@ export class SessionService {
21662177
// permanent disconnect (archive, delete, fresh session) may drop them.
21672178
this.d.adapterStore.removeAdapter(taskRunId);
21682179
this.d.removePersistedConfigOptions(taskRunId);
2180+
this.d.removePersistedAlwaysOnSkillInstructions?.(taskRunId);
21692181
}
21702182
}
21712183

@@ -2404,6 +2416,12 @@ export class SessionService {
24042416
session.executionMode = executionMode;
24052417
session.reasoningLevel = reasoningLevel;
24062418
session.alwaysOnSkillInstructions = alwaysOnSkillInstructions;
2419+
if (alwaysOnSkillInstructions) {
2420+
this.d.setPersistedAlwaysOnSkillInstructions?.(
2421+
taskRun.id,
2422+
alwaysOnSkillInstructions,
2423+
);
2424+
}
24072425

24082426
// An imported CLI session had its history replayed during agent.start;
24092427
// the replay is already in the local run log, so load it for the UI.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {
2+
flushRendererStateWrites,
3+
registerRendererStateStorage,
4+
} from "@posthog/ui/shell/rendererStorage";
5+
import { beforeEach, describe, expect, it, vi } from "vitest";
6+
import {
7+
getPersistedAlwaysOnSkillInstructions,
8+
removePersistedAlwaysOnSkillInstructions,
9+
setPersistedAlwaysOnSkillInstructions,
10+
useSessionConfigStore,
11+
} from "./sessionConfigStore";
12+
13+
const getItem = vi.fn();
14+
const setItem = vi.fn();
15+
const removeItem = vi.fn();
16+
17+
registerRendererStateStorage({ getItem, setItem, removeItem });
18+
19+
describe("sessionConfigStore always-on skill instructions", () => {
20+
beforeEach(async () => {
21+
await flushRendererStateWrites();
22+
getItem.mockReset();
23+
setItem.mockReset();
24+
removeItem.mockReset();
25+
getItem.mockResolvedValue(null);
26+
setItem.mockResolvedValue(undefined);
27+
removeItem.mockResolvedValue(undefined);
28+
useSessionConfigStore.setState({
29+
configsByRunId: {},
30+
alwaysOnSkillInstructionsByRunId: {},
31+
});
32+
});
33+
34+
it("persists instructions by task run until they are removed", async () => {
35+
setPersistedAlwaysOnSkillInstructions("run-1", "Follow this skill");
36+
37+
expect(getPersistedAlwaysOnSkillInstructions("run-1")).toBe(
38+
"Follow this skill",
39+
);
40+
await flushRendererStateWrites();
41+
expect(setItem).toHaveBeenCalledWith(
42+
"session-config-storage",
43+
expect.stringContaining("Follow this skill"),
44+
);
45+
46+
removePersistedAlwaysOnSkillInstructions("run-1");
47+
expect(getPersistedAlwaysOnSkillInstructions("run-1")).toBeUndefined();
48+
});
49+
});

packages/ui/src/features/sessions/sessionConfigStore.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { create } from "zustand";
44
import { persist } from "zustand/middleware";
55

66
interface SessionConfigState {
7-
/** Map of taskRunId -> persisted config options */
87
configsByRunId: Record<string, SessionConfigOption[]>;
8+
alwaysOnSkillInstructionsByRunId: Record<string, string>;
99
}
1010

1111
interface SessionConfigActions {
@@ -15,6 +15,12 @@ interface SessionConfigActions {
1515
getConfigOptions: (taskRunId: string) => SessionConfigOption[] | undefined;
1616
/** Remove config options for a task run */
1717
removeConfigOptions: (taskRunId: string) => void;
18+
setAlwaysOnSkillInstructions: (
19+
taskRunId: string,
20+
instructions: string,
21+
) => void;
22+
getAlwaysOnSkillInstructions: (taskRunId: string) => string | undefined;
23+
removeAlwaysOnSkillInstructions: (taskRunId: string) => void;
1824
}
1925

2026
type SessionConfigStore = SessionConfigState & SessionConfigActions;
@@ -23,6 +29,7 @@ export const useSessionConfigStore = create<SessionConfigStore>()(
2329
persist(
2430
(set, get) => ({
2531
configsByRunId: {},
32+
alwaysOnSkillInstructionsByRunId: {},
2633

2734
setConfigOptions: (taskRunId, options) =>
2835
set((state) => ({
@@ -36,11 +43,30 @@ export const useSessionConfigStore = create<SessionConfigStore>()(
3643
const { [taskRunId]: _removed, ...rest } = state.configsByRunId;
3744
return { configsByRunId: rest };
3845
}),
46+
setAlwaysOnSkillInstructions: (taskRunId, instructions) =>
47+
set((state) => ({
48+
alwaysOnSkillInstructionsByRunId: {
49+
...state.alwaysOnSkillInstructionsByRunId,
50+
[taskRunId]: instructions,
51+
},
52+
})),
53+
getAlwaysOnSkillInstructions: (taskRunId) =>
54+
get().alwaysOnSkillInstructionsByRunId[taskRunId],
55+
removeAlwaysOnSkillInstructions: (taskRunId) =>
56+
set((state) => {
57+
const { [taskRunId]: _removed, ...rest } =
58+
state.alwaysOnSkillInstructionsByRunId;
59+
return { alwaysOnSkillInstructionsByRunId: rest };
60+
}),
3961
}),
4062
{
4163
name: "session-config-storage",
4264
storage: electronStorage,
43-
partialize: (state) => ({ configsByRunId: state.configsByRunId }),
65+
partialize: (state) => ({
66+
configsByRunId: state.configsByRunId,
67+
alwaysOnSkillInstructionsByRunId:
68+
state.alwaysOnSkillInstructionsByRunId,
69+
}),
4470
},
4571
),
4672
);
@@ -64,3 +90,26 @@ export function setPersistedConfigOptions(
6490
export function removePersistedConfigOptions(taskRunId: string): void {
6591
useSessionConfigStore.getState().removeConfigOptions(taskRunId);
6692
}
93+
94+
export function getPersistedAlwaysOnSkillInstructions(
95+
taskRunId: string,
96+
): string | undefined {
97+
return useSessionConfigStore
98+
.getState()
99+
.getAlwaysOnSkillInstructions(taskRunId);
100+
}
101+
102+
export function setPersistedAlwaysOnSkillInstructions(
103+
taskRunId: string,
104+
instructions: string,
105+
): void {
106+
useSessionConfigStore
107+
.getState()
108+
.setAlwaysOnSkillInstructions(taskRunId, instructions);
109+
}
110+
111+
export function removePersistedAlwaysOnSkillInstructions(
112+
taskRunId: string,
113+
): void {
114+
useSessionConfigStore.getState().removeAlwaysOnSkillInstructions(taskRunId);
115+
}

packages/ui/src/features/sessions/sessionServiceHost.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,11 @@ import { NotificationBus } from "@posthog/ui/features/notifications/notification
3131
import { SpeechNotifier } from "@posthog/ui/features/notifications/speechNotifier";
3232
import { useSessionAdapterStore } from "@posthog/ui/features/sessions/sessionAdapterStore";
3333
import {
34+
getPersistedAlwaysOnSkillInstructions,
3435
getPersistedConfigOptions,
36+
removePersistedAlwaysOnSkillInstructions,
3537
removePersistedConfigOptions,
38+
setPersistedAlwaysOnSkillInstructions,
3639
setPersistedConfigOptions,
3740
} from "@posthog/ui/features/sessions/sessionConfigStore";
3841
import { sessionStoreSetters } from "@posthog/ui/features/sessions/sessionStore";
@@ -117,6 +120,9 @@ function buildSessionServiceDeps(): SessionServiceDeps {
117120
getPersistedConfigOptions(taskRunId) ?? undefined,
118121
setPersistedConfigOptions,
119122
removePersistedConfigOptions,
123+
getPersistedAlwaysOnSkillInstructions,
124+
setPersistedAlwaysOnSkillInstructions,
125+
removePersistedAlwaysOnSkillInstructions,
120126
adapterStore: {
121127
getAdapter: (taskRunId) =>
122128
useSessionAdapterStore.getState().getAdapter(taskRunId),

packages/ui/src/features/task-detail/hooks/useTaskCreation.ts

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -322,30 +322,36 @@ export function useTaskCreation({
322322
let alwaysOnSkills: AlwaysOnSkillRef[] = settings.alwaysOnSkills;
323323
let alwaysOnSkillInstructions: string | undefined;
324324
while (alwaysOnSkills.length > 0) {
325-
try {
326-
alwaysOnSkillInstructions =
327-
await hostClient.skills.renderAlwaysOn.query(alwaysOnSkills);
325+
const rendered =
326+
await hostClient.skills.renderAlwaysOn.query(alwaysOnSkills);
327+
alwaysOnSkillInstructions = rendered.instructions;
328+
if (rendered.failures.length === 0) {
328329
break;
329-
} catch (error) {
330-
const action = await useAlwaysOnSkillsFailureStore
331-
.getState()
332-
.confirm(
333-
error instanceof Error ? error.message : String(error),
334-
alwaysOnSkills,
335-
);
336-
if (action === "retry") continue;
337-
if (action === "cancel") {
338-
setIsCreatingTask(false);
339-
return false;
340-
}
341-
if (action === "disable") {
342-
for (const skill of alwaysOnSkills) {
343-
useSettingsStore.getState().setSkillAlwaysOn(skill, false);
344-
}
330+
}
331+
const failedSkills = rendered.failures.map(({ skill }) => skill);
332+
const action = await useAlwaysOnSkillsFailureStore
333+
.getState()
334+
.confirm(
335+
rendered.failures.map(({ error }) => error).join("\n"),
336+
failedSkills,
337+
);
338+
if (action === "retry") continue;
339+
if (action === "cancel") {
340+
setIsCreatingTask(false);
341+
return false;
342+
}
343+
if (action === "disable") {
344+
for (const skill of failedSkills) {
345+
useSettingsStore.getState().setSkillAlwaysOn(skill, false);
345346
}
346-
alwaysOnSkills = [];
347-
alwaysOnSkillInstructions = undefined;
348347
}
348+
const failedKeys = new Set(
349+
failedSkills.map((skill) => `${skill.source}:${skill.path}`),
350+
);
351+
alwaysOnSkills = alwaysOnSkills.filter(
352+
(skill) => !failedKeys.has(`${skill.source}:${skill.path}`),
353+
);
354+
break;
349355
}
350356

351357
const shouldShowPendingView = !onTaskCreated && !!plainPromptText;

packages/workspace-server/src/services/skills/schemas.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,15 @@ export const resolveSkillDependenciesInput = z.array(bundleLocalSkillInput);
139139
export const resolveSkillDependenciesOutput = z.array(bundleLocalSkillInput);
140140

141141
export const renderAlwaysOnSkillsInput = z.array(bundleLocalSkillInput);
142-
export const renderAlwaysOnSkillsOutput = z.string();
142+
export const renderAlwaysOnSkillsOutput = z.object({
143+
instructions: z.string().optional(),
144+
failures: z.array(
145+
z.object({
146+
skill: bundleLocalSkillInput,
147+
error: z.string(),
148+
}),
149+
),
150+
});
143151

144152
export type BundleLocalSkillInput = z.infer<typeof bundleLocalSkillInput>;
145153
export type BundleLocalSkillOutput = z.infer<typeof bundleLocalSkillOutput>;

packages/workspace-server/src/services/skills/skills.test.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -759,10 +759,28 @@ describe("renderAlwaysOnSkillInstructions", () => {
759759
{ name: "first", source: "repo", path: first },
760760
]);
761761

762-
expect(rendered.indexOf("## first")).toBeLessThan(
763-
rendered.indexOf("## second"),
762+
expect(rendered.instructions?.indexOf("## first")).toBeLessThan(
763+
rendered.instructions?.indexOf("## second") ?? -1,
764764
);
765-
expect(rendered).toContain(`Installed at: ${first}`);
766-
expect(rendered).not.toContain("description: about first");
765+
expect(rendered.instructions).toContain(`Installed at: ${first}`);
766+
expect(rendered.instructions).not.toContain("description: about first");
767+
expect(rendered.failures).toEqual([]);
768+
});
769+
770+
it("renders readable skills and reports unreadable skills separately", async () => {
771+
const readable = await createSkill(repoSkillsDir, "readable");
772+
773+
const rendered = await makeService().renderAlwaysOnSkillInstructions([
774+
{
775+
name: "missing",
776+
source: "repo",
777+
path: path.join(repoSkillsDir, "missing"),
778+
},
779+
{ name: "readable", source: "repo", path: readable },
780+
]);
781+
782+
expect(rendered.instructions).toContain("## readable");
783+
expect(rendered.failures).toHaveLength(1);
784+
expect(rendered.failures[0]?.skill.name).toBe("missing");
767785
});
768786
});

0 commit comments

Comments
 (0)