Skip to content

Commit 51043bc

Browse files
authored
Merge pull request #133 from odefun/feat/refactor-settings-modules-177193
refactor settings and workspace sync modules
2 parents e3d028b + ad9e502 commit 51043bc

File tree

19 files changed

+1415
-1120
lines changed

19 files changed

+1415
-1120
lines changed

packages/agents/test/session-inspector.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,90 @@ describe("session inspector", () => {
289289
expect(text).not.toContain("cost 0");
290290
});
291291

292+
it("hydrates OpenCode token usage from nested message info shape", () => {
293+
const startedAt = Date.now();
294+
const state = buildSessionMessageState([
295+
{
296+
timestamp: startedAt,
297+
type: "message.updated",
298+
data: {
299+
payload: {
300+
type: "message.updated",
301+
properties: {
302+
message: {
303+
info: {
304+
title: "Refactor live status parser",
305+
modelId: "gpt-5.3-codex",
306+
agentName: "build",
307+
usage: {
308+
total_tokens: 1024,
309+
input_tokens: 200,
310+
output_tokens: 120,
311+
reasoning_tokens: 4,
312+
cache_tokens: {
313+
input_tokens: 700,
314+
output_tokens: 0,
315+
},
316+
},
317+
},
318+
},
319+
},
320+
},
321+
},
322+
},
323+
]);
324+
325+
expect(state.sessionTitle).toBeUndefined();
326+
expect(state.model).toBe("gpt-5.3-codex");
327+
expect(state.agent).toBe("build");
328+
expect(state.tokenUsage?.total).toBe(1024);
329+
expect(state.tokenUsage?.cacheRead).toBe(700);
330+
});
331+
332+
it("does not clear token usage when later message.updated has empty usage object", () => {
333+
const startedAt = Date.now();
334+
const state = buildSessionMessageState([
335+
{
336+
timestamp: startedAt,
337+
type: "message.updated",
338+
data: {
339+
payload: {
340+
type: "message.updated",
341+
properties: {
342+
info: {
343+
modelID: "gpt-5.3-codex",
344+
tokens: {
345+
total: 2048,
346+
input: 500,
347+
output: 200,
348+
reasoning: 10,
349+
cache: { read: 1338, write: 0 },
350+
},
351+
},
352+
},
353+
},
354+
},
355+
},
356+
{
357+
timestamp: startedAt + 1,
358+
type: "message.updated",
359+
data: {
360+
payload: {
361+
type: "message.updated",
362+
properties: {
363+
info: {
364+
tokens: {},
365+
},
366+
},
367+
},
368+
},
369+
},
370+
]);
371+
372+
expect(state.tokenUsage?.total).toBe(2048);
373+
expect(state.tokenUsage?.input).toBe(500);
374+
});
375+
292376
it("parses todo.updated aliases from wrapped payload events", () => {
293377
const now = Date.now();
294378
const state = buildSessionMessageState([

packages/config/dashboard-config.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
parseStatusMessageFrequencyMs,
44
type StatusMessageFrequencyMs,
55
} from "./status-message-frequency";
6+
import { isAgentProviderId, type AgentProviderId } from "@/shared/agent-provider";
67

78
export type DashboardConfig = {
89
completeOnboarding: boolean;
@@ -69,7 +70,7 @@ export type DashboardConfig = {
6970
channelDetails: {
7071
id: string;
7172
name: string;
72-
agentProvider?: "opencode" | "claudecode" | "codex" | "kimi" | "kiro" | "kilo" | "qwen" | "goose" | "gemini";
73+
agentProvider?: AgentProviderId;
7374
model: string;
7475
workingDirectory: string;
7576
baseBranch: string;
@@ -152,16 +153,10 @@ const asGitStrategy = (
152153
const asStatus = (value: unknown): DashboardConfig["workspaces"][number]["status"] =>
153154
value === "paused" ? "paused" : "active";
154155

155-
const KNOWN_AGENT_PROVIDERS = new Set<NonNullable<
156-
DashboardConfig["workspaces"][number]["channelDetails"][number]["agentProvider"]
157-
>>(["opencode", "claudecode", "codex", "kimi", "kiro", "kilo", "qwen", "goose", "gemini"]);
158-
159156
function isKnownAgentProvider(
160157
value: string
161158
): value is NonNullable<DashboardConfig["workspaces"][number]["channelDetails"][number]["agentProvider"]> {
162-
return KNOWN_AGENT_PROVIDERS.has(value as NonNullable<
163-
DashboardConfig["workspaces"][number]["channelDetails"][number]["agentProvider"]
164-
>);
159+
return isAgentProviderId(value);
165160
}
166161

167162
const asAgentProvider = (
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { normalizeCwd } from "../paths";
2+
import { isAgentProviderId } from "@/shared/agent-provider";
3+
import {
4+
type AgentProvider,
5+
type ChannelDetail,
6+
type WorkspaceConfig,
7+
} from "./ode-schema";
8+
import {
9+
loadOdeConfig,
10+
normalizeBaseBranch,
11+
updateOdeConfig,
12+
} from "./ode-store";
13+
14+
export type ChannelCwdInfo = {
15+
cwd: string;
16+
workingDirectory: string | null;
17+
hasCustomCwd: boolean;
18+
};
19+
20+
export function getDefaultCwd(): string {
21+
return normalizeCwd(process.cwd());
22+
}
23+
24+
function getWorkspaces(): WorkspaceConfig[] {
25+
return loadOdeConfig().workspaces;
26+
}
27+
28+
export function getChannelDetails(channelId: string): ChannelDetail | null {
29+
for (const workspace of getWorkspaces()) {
30+
const match = workspace.channelDetails.find((channel) => channel.id === channelId);
31+
if (match) return match;
32+
}
33+
return null;
34+
}
35+
36+
export function resolveChannelCwd(channelId: string): ChannelCwdInfo {
37+
const channel = getChannelDetails(channelId);
38+
const workingDirectory = channel?.workingDirectory?.trim();
39+
const normalized = workingDirectory && workingDirectory.length > 0
40+
? normalizeCwd(workingDirectory)
41+
: null;
42+
return {
43+
cwd: normalized ?? getDefaultCwd(),
44+
workingDirectory: normalized,
45+
hasCustomCwd: Boolean(normalized),
46+
};
47+
}
48+
49+
export function setChannelCwd(channelId: string, cwd: string): void {
50+
updateChannel(channelId, (channel) => ({
51+
...channel,
52+
workingDirectory: normalizeCwd(cwd),
53+
}));
54+
}
55+
56+
export function setChannelWorkingDirectory(channelId: string, workingDirectory: string | null): void {
57+
const normalized = workingDirectory && workingDirectory.trim().length > 0
58+
? normalizeCwd(workingDirectory)
59+
: "";
60+
updateChannel(channelId, (channel) => ({
61+
...channel,
62+
workingDirectory: normalized,
63+
}));
64+
}
65+
66+
export function getChannelBaseBranch(channelId: string): string {
67+
return normalizeBaseBranch(getChannelDetails(channelId)?.baseBranch);
68+
}
69+
70+
export function setChannelBaseBranch(channelId: string, baseBranch: string | null): void {
71+
const normalized = normalizeBaseBranch(baseBranch);
72+
updateChannel(channelId, (channel) => ({
73+
...channel,
74+
baseBranch: normalized,
75+
}));
76+
}
77+
78+
export function getChannelSystemMessage(channelId: string): string | null {
79+
return getChannelDetails(channelId)?.channelSystemMessage ?? null;
80+
}
81+
82+
export function setChannelSystemMessage(channelId: string, channelSystemMessage: string | null): void {
83+
const normalized = channelSystemMessage?.trim() ?? "";
84+
updateChannel(channelId, (channel) => ({
85+
...channel,
86+
channelSystemMessage: normalized,
87+
}));
88+
}
89+
90+
export function getChannelModel(channelId: string): string | null {
91+
return getChannelDetails(channelId)?.model ?? null;
92+
}
93+
94+
export function getChannelAgentProvider(channelId: string): AgentProvider {
95+
const provider = getChannelDetails(channelId)?.agentProvider;
96+
return isAgentProviderId(provider) ? provider : "opencode";
97+
}
98+
99+
export function setChannelModel(channelId: string, model: string): void {
100+
updateChannel(channelId, (channel) => ({ ...channel, model }));
101+
}
102+
103+
export function setChannelAgentProvider(
104+
channelId: string,
105+
agentProvider: AgentProvider
106+
): void {
107+
updateChannel(channelId, (channel) => ({ ...channel, agentProvider }));
108+
}
109+
110+
function updateChannel(
111+
channelId: string,
112+
updater: (channel: ChannelDetail) => ChannelDetail
113+
): void {
114+
let updated = false;
115+
updateOdeConfig((config) => {
116+
const workspaces = config.workspaces.map((workspace) => {
117+
const channelDetails = workspace.channelDetails.map((channel) => {
118+
if (channel.id !== channelId) return channel;
119+
updated = true;
120+
return updater(channel);
121+
});
122+
return { ...workspace, channelDetails };
123+
});
124+
125+
if (!updated) {
126+
throw new Error("Channel not found in ~/.config/ode/ode.json");
127+
}
128+
129+
return { ...config, workspaces };
130+
});
131+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { z } from "zod";
2+
import { AGENT_PROVIDERS } from "@/shared/agent-provider";
3+
import { DEFAULT_STATUS_MESSAGE_FREQUENCY_MS } from "../status-message-frequency";
4+
5+
const DEFAULT_UPDATE_INTERVAL_MS = 60 * 60 * 1000;
6+
7+
const userSchema = z.object({
8+
name: z.string().optional().default(""),
9+
email: z.string().optional().default(""),
10+
initials: z.string().optional().default(""),
11+
avatar: z.string().optional().default(""),
12+
gitStrategy: z.enum(["default", "worktree"]).optional().default("worktree"),
13+
defaultStatusMessageFormat: z.enum(["minimum", "medium", "aggressive"]).optional().default("medium"),
14+
defaultMessageFrequency: z.enum(["minimum", "medium", "aggressive"]).optional(),
15+
messageUpdateIntervalMs: z.number().optional(),
16+
IM_MESSAGE_UPDATE_INTERVAL_MS: z.number().optional().default(DEFAULT_STATUS_MESSAGE_FREQUENCY_MS),
17+
});
18+
19+
export const agentProviderSchema = z.enum(AGENT_PROVIDERS);
20+
21+
const agentsSchema = z.object({
22+
opencode: z.object({
23+
enabled: z.boolean().optional().default(true),
24+
models: z.array(z.string()).optional().default([]),
25+
}).optional().default({ enabled: true, models: [] }),
26+
claudecode: z.object({
27+
enabled: z.boolean().optional().default(true),
28+
}).optional().default({ enabled: true }),
29+
codex: z.object({
30+
enabled: z.boolean().optional().default(true),
31+
models: z.array(z.string()).optional().default([]),
32+
}).optional().default({ enabled: true, models: [] }),
33+
kimi: z.object({
34+
enabled: z.boolean().optional().default(true),
35+
}).optional().default({ enabled: true }),
36+
kiro: z.object({
37+
enabled: z.boolean().optional().default(true),
38+
}).optional().default({ enabled: true }),
39+
kilo: z.object({
40+
enabled: z.boolean().optional().default(true),
41+
models: z.array(z.string()).optional().default([]),
42+
}).optional().default({ enabled: true, models: [] }),
43+
qwen: z.object({
44+
enabled: z.boolean().optional().default(true),
45+
}).optional().default({ enabled: true }),
46+
goose: z.object({
47+
enabled: z.boolean().optional().default(true),
48+
}).optional().default({ enabled: true }),
49+
gemini: z.object({
50+
enabled: z.boolean().optional().default(true),
51+
}).optional().default({ enabled: true }),
52+
}).optional().default({
53+
opencode: { enabled: true, models: [] },
54+
claudecode: { enabled: true },
55+
codex: { enabled: true, models: [] },
56+
kimi: { enabled: true },
57+
kiro: { enabled: true },
58+
kilo: { enabled: true, models: [] },
59+
qwen: { enabled: true },
60+
goose: { enabled: true },
61+
gemini: { enabled: true },
62+
});
63+
64+
const channelDetailSchema = z.object({
65+
id: z.string(),
66+
name: z.string(),
67+
agentProvider: z.preprocess(
68+
(value) => (value === "claude" ? "claudecode" : value),
69+
agentProviderSchema.optional().default("opencode")
70+
),
71+
model: z.string().optional().default(""),
72+
workingDirectory: z.string().optional().default(""),
73+
baseBranch: z.string().optional().default("main"),
74+
channelSystemMessage: z.string().optional().default(""),
75+
});
76+
77+
const updateSchema = z.object({
78+
autoUpgrade: z.boolean().optional().default(true),
79+
checkIntervalMs: z.number().optional().default(DEFAULT_UPDATE_INTERVAL_MS),
80+
});
81+
82+
const workspaceSchema = z.object({
83+
id: z.string(),
84+
type: z.enum(["slack", "discord", "lark"]).optional().default("slack"),
85+
name: z.string().optional().default(""),
86+
domain: z.string().optional().default(""),
87+
status: z.enum(["active", "paused"]).optional().default("active"),
88+
channels: z.number().optional().default(0),
89+
members: z.number().optional().default(0),
90+
lastSync: z.string().optional().default(""),
91+
slackAppToken: z.string().optional().default(""),
92+
slackBotToken: z.string().optional().default(""),
93+
discordBotToken: z.string().optional().default(""),
94+
larkAppKey: z.string().optional().default(""),
95+
larkAppId: z.string().optional().default(""),
96+
larkAppSecret: z.string().optional().default(""),
97+
channelDetails: z.array(channelDetailSchema).optional().default([]),
98+
});
99+
100+
export const odeConfigSchema = z.object({
101+
user: userSchema,
102+
githubInfos: z
103+
.record(
104+
z.string(),
105+
z.object({
106+
token: z.string().optional().default(""),
107+
gitName: z.string().optional().default(""),
108+
gitEmail: z.string().optional().default(""),
109+
})
110+
)
111+
.optional()
112+
.default({}),
113+
agents: agentsSchema,
114+
completeOnboarding: z.boolean().optional().default(false),
115+
workspaces: z.array(workspaceSchema),
116+
updates: updateSchema.optional().default({
117+
autoUpgrade: true,
118+
checkIntervalMs: DEFAULT_UPDATE_INTERVAL_MS,
119+
}),
120+
});
121+
122+
export type ChannelDetail = z.infer<typeof channelDetailSchema>;
123+
export type WorkspaceConfig = z.infer<typeof workspaceSchema>;
124+
export type AgentProvider = z.infer<typeof agentProviderSchema>;
125+
export type AgentsConfig = z.infer<typeof agentsSchema>;
126+
export type UpdateConfig = z.infer<typeof updateSchema>;
127+
export type OdeConfig = z.infer<typeof odeConfigSchema>;
128+
export type UserConfig = z.infer<typeof userSchema>;

0 commit comments

Comments
 (0)