Skip to content

Commit f202226

Browse files
authored
refactor(flags): move bash timeout to runtime flags (anomalyco#27607)
1 parent 34198f4 commit f202226

5 files changed

Lines changed: 64 additions & 5 deletions

File tree

packages/core/src/flag/flag.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ export const Flag = {
5656
OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT:
5757
copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"),
5858
OPENCODE_ENABLE_EXA: truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA"),
59-
OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
6059
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: number("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
6160
OPENCODE_EXPERIMENTAL_OXFMT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT"),
6261
OPENCODE_EXPERIMENTAL_LSP_TY: truthy("OPENCODE_EXPERIMENTAL_LSP_TY"),

packages/opencode/src/effect/runtime-flags.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { Config, ConfigProvider, Context, Effect, Layer } from "effect"
22
import { ConfigService } from "@/effect/config-service"
33

44
const bool = (name: string) => Config.boolean(name).pipe(Config.withDefault(false))
5+
const positiveInteger = (name: string) =>
6+
Config.number(name).pipe(
7+
Config.map((value) => (Number.isInteger(value) && value > 0 ? value : undefined)),
8+
Config.orElse(() => Config.succeed(undefined)),
9+
)
510
const experimental = bool("OPENCODE_EXPERIMENTAL")
611
const enabledByExperimental = (name: string) =>
712
Config.all({ experimental, enabled: bool(name) }).pipe(Config.map((flags) => flags.experimental || flags.enabled))
@@ -30,6 +35,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
3035
experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
3136
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
3237
experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
38+
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
3339
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
3440
}) {}
3541

packages/opencode/src/tool/shell.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { Language, type Node } from "web-tree-sitter"
1212
import { AppFileSystem } from "@opencode-ai/core/filesystem"
1313
import { fileURLToPath } from "url"
1414
import { Config } from "@/config/config"
15-
import { Flag } from "@opencode-ai/core/flag/flag"
15+
import { RuntimeFlags } from "@/effect/runtime-flags"
1616
import { Shell } from "@/shell/shell"
1717
import { ShellID } from "./shell/id"
1818

@@ -26,7 +26,6 @@ import { BashArity } from "@/permission/arity"
2626
export { Parameters } from "./shell/prompt"
2727

2828
const MAX_METADATA_LENGTH = 30_000
29-
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
3029
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
3130
const FILES = new Set([
3231
...CWD,
@@ -340,6 +339,8 @@ export const ShellTool = Tool.define(
340339
const fs = yield* AppFileSystem.Service
341340
const trunc = yield* Truncate.Service
342341
const plugin = yield* Plugin.Service
342+
const flags = yield* RuntimeFlags.Service
343+
const defaultTimeout = flags.bashDefaultTimeoutMs ?? 2 * 60 * 1000
343344

344345
const cygpath = Effect.fn("ShellTool.cygpath")(function* (shell: string, text: string) {
345346
const lines = yield* spawner
@@ -615,7 +616,7 @@ export const ShellTool = Tool.define(
615616
if (params.timeout !== undefined && params.timeout < 0) {
616617
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
617618
}
618-
const timeout = params.timeout ?? DEFAULT_TIMEOUT
619+
const timeout = params.timeout ?? defaultTimeout
619620
const ps = Shell.ps(shell)
620621
yield* Effect.scoped(
621622
Effect.gen(function* () {

packages/opencode/test/effect/runtime-flags.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,17 +44,49 @@ describe("RuntimeFlags", () => {
4444

4545
it.effect("layer accepts partial test overrides and fills defaults from Config definitions", () =>
4646
Effect.gen(function* () {
47-
const flags = yield* readFlags.pipe(Effect.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })))
47+
const flags = yield* readFlags.pipe(
48+
Effect.provide(RuntimeFlags.layer({ disableDefaultPlugins: true, bashDefaultTimeoutMs: 1_000 })),
49+
)
4850

4951
expect(flags.pure).toBe(false)
5052
expect(flags.disableDefaultPlugins).toBe(true)
5153
expect(flags.disableClaudeCodeSkills).toBe(false)
5254
expect(flags.enableExa).toBe(false)
55+
expect(flags.bashDefaultTimeoutMs).toBe(1_000)
5356
expect(flags.enableExperimentalModels).toBe(false)
5457
expect(flags.client).toBe("cli")
5558
}),
5659
)
5760

61+
for (const input of [
62+
{ name: "absent", config: {}, expected: undefined },
63+
{
64+
name: "valid positive integer",
65+
config: { OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "1234" },
66+
expected: 1234,
67+
},
68+
{
69+
name: "invalid string",
70+
config: { OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "nope" },
71+
expected: undefined,
72+
},
73+
{ name: "zero", config: { OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "0" }, expected: undefined },
74+
{ name: "negative", config: { OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "-1" }, expected: undefined },
75+
{
76+
name: "non-integer",
77+
config: { OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "1.5" },
78+
expected: undefined,
79+
},
80+
]) {
81+
it.effect(`parses bashDefaultTimeoutMs from config: ${input.name}`, () =>
82+
Effect.gen(function* () {
83+
const flags = yield* readFlags.pipe(Effect.provide(fromConfig(input.config)))
84+
85+
expect(flags.bashDefaultTimeoutMs).toBe(input.expected)
86+
}),
87+
)
88+
}
89+
5890
it.effect("layer ignores the active ConfigProvider for omitted test overrides", () =>
5991
Effect.gen(function* () {
6092
const flags = yield* readFlags.pipe(
@@ -66,6 +98,7 @@ describe("RuntimeFlags", () => {
6698
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
6799
OPENCODE_EXPERIMENTAL: "true",
68100
OPENCODE_ENABLE_EXA: "true",
101+
OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: "1234",
69102
OPENCODE_CLIENT: "desktop",
70103
}),
71104
),
@@ -76,6 +109,7 @@ describe("RuntimeFlags", () => {
76109
expect(flags.disableDefaultPlugins).toBe(false)
77110
expect(flags.disableClaudeCodeSkills).toBe(false)
78111
expect(flags.enableExa).toBe(false)
112+
expect(flags.bashDefaultTimeoutMs).toBeUndefined()
79113
expect(flags.client).toBe("cli")
80114
}),
81115
)

packages/opencode/test/tool/shell.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
1717
import { Plugin } from "../../src/plugin"
1818
import { testEffect } from "../lib/effect"
1919
import { Tool } from "@/tool/tool"
20+
import { RuntimeFlags } from "@/effect/runtime-flags"
2021

2122
const shellLayer = Layer.mergeAll(
2223
CrossSpawnSpawner.defaultLayer,
@@ -25,6 +26,7 @@ const shellLayer = Layer.mergeAll(
2526
Truncate.defaultLayer,
2627
Config.defaultLayer,
2728
Agent.defaultLayer,
29+
RuntimeFlags.defaultLayer,
2830
)
2931
const it = testEffect(shellLayer)
3032
type ShellTestServices =
@@ -1077,6 +1079,23 @@ describe("tool.shell abort", () => {
10771079
15_000,
10781080
)
10791081

1082+
it.live(
1083+
"uses RuntimeFlags bashDefaultTimeoutMs when timeout is omitted",
1084+
() =>
1085+
runIn(
1086+
projectRoot,
1087+
Effect.gen(function* () {
1088+
const result = yield* run({
1089+
command: `echo started && sleep 60`,
1090+
description: "Default timeout test",
1091+
})
1092+
expect(result.output).toContain("started")
1093+
expect(result.output).toContain("exceeding timeout 500 ms")
1094+
}),
1095+
).pipe(Effect.provide(RuntimeFlags.layer({ bashDefaultTimeoutMs: 500 }))),
1096+
15_000,
1097+
)
1098+
10801099
if (process.platform !== "win32") {
10811100
it.live("captures stderr in output", () =>
10821101
runIn(

0 commit comments

Comments
 (0)