Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
456d9cea65 | ||
|
|
30f893b766 | ||
|
|
c905e1cb7a | ||
|
|
d3e2b36e3d | ||
|
|
5f0b6d49f5 | ||
|
|
b45408dd9c |
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "3.1.7",
|
||||
"version": "3.1.8",
|
||||
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -74,13 +74,13 @@
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.1.7",
|
||||
"oh-my-opencode-darwin-x64": "3.1.7",
|
||||
"oh-my-opencode-linux-arm64": "3.1.7",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.1.7",
|
||||
"oh-my-opencode-linux-x64": "3.1.7",
|
||||
"oh-my-opencode-linux-x64-musl": "3.1.7",
|
||||
"oh-my-opencode-windows-x64": "3.1.7"
|
||||
"oh-my-opencode-darwin-arm64": "3.1.8",
|
||||
"oh-my-opencode-darwin-x64": "3.1.8",
|
||||
"oh-my-opencode-linux-arm64": "3.1.8",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.1.8",
|
||||
"oh-my-opencode-linux-x64": "3.1.8",
|
||||
"oh-my-opencode-linux-x64-musl": "3.1.8",
|
||||
"oh-my-opencode-windows-x64": "3.1.8"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-arm64",
|
||||
"version": "3.1.7",
|
||||
"version": "3.1.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-x64",
|
||||
"version": "3.1.7",
|
||||
"version": "3.1.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64-musl",
|
||||
"version": "3.1.7",
|
||||
"version": "3.1.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64",
|
||||
"version": "3.1.7",
|
||||
"version": "3.1.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-musl",
|
||||
"version": "3.1.7",
|
||||
"version": "3.1.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64",
|
||||
"version": "3.1.7",
|
||||
"version": "3.1.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-windows-x64",
|
||||
"version": "3.1.7",
|
||||
"version": "3.1.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -967,6 +967,14 @@
|
||||
"created_at": "2026-01-29T09:00:28Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1241
|
||||
},
|
||||
{
|
||||
"name": "LeekJay",
|
||||
"id": 39609783,
|
||||
"comment_id": 3819009761,
|
||||
"created_at": "2026-01-29T17:03:24Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1254
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -82,6 +82,7 @@ describe("createEventState", () => {
|
||||
expect(state.lastOutput).toBe("")
|
||||
expect(state.lastPartText).toBe("")
|
||||
expect(state.currentTool).toBe(null)
|
||||
expect(state.hasReceivedMeaningfulWork).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -126,6 +127,119 @@ describe("event handling", () => {
|
||||
expect(state.mainSessionIdle).toBe(false)
|
||||
})
|
||||
|
||||
it("hasReceivedMeaningfulWork is false initially after session.idle", async () => {
|
||||
// #given - session goes idle without any assistant output (race condition scenario)
|
||||
const ctx = createMockContext("my-session")
|
||||
const state = createEventState()
|
||||
|
||||
const payload: EventPayload = {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "my-session" },
|
||||
}
|
||||
|
||||
const events = toAsyncIterable([payload])
|
||||
const { processEvents } = await import("./events")
|
||||
|
||||
// #when
|
||||
await processEvents(ctx, events, state)
|
||||
|
||||
// #then - idle but no meaningful work yet
|
||||
expect(state.mainSessionIdle).toBe(true)
|
||||
expect(state.hasReceivedMeaningfulWork).toBe(false)
|
||||
})
|
||||
|
||||
it("message.updated with assistant role sets hasReceivedMeaningfulWork", async () => {
|
||||
// #given
|
||||
const ctx = createMockContext("my-session")
|
||||
const state = createEventState()
|
||||
|
||||
const payload: EventPayload = {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { sessionID: "my-session", role: "assistant" },
|
||||
},
|
||||
}
|
||||
|
||||
const events = toAsyncIterable([payload])
|
||||
const { processEvents } = await import("./events")
|
||||
|
||||
// #when
|
||||
await processEvents(ctx, events, state)
|
||||
|
||||
// #then
|
||||
expect(state.hasReceivedMeaningfulWork).toBe(true)
|
||||
})
|
||||
|
||||
it("message.updated with user role does not set hasReceivedMeaningfulWork", async () => {
|
||||
// #given - user message should not count as meaningful work
|
||||
const ctx = createMockContext("my-session")
|
||||
const state = createEventState()
|
||||
|
||||
const payload: EventPayload = {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { sessionID: "my-session", role: "user" },
|
||||
},
|
||||
}
|
||||
|
||||
const events = toAsyncIterable([payload])
|
||||
const { processEvents } = await import("./events")
|
||||
|
||||
// #when
|
||||
await processEvents(ctx, events, state)
|
||||
|
||||
// #then - user role should not count as meaningful work
|
||||
expect(state.hasReceivedMeaningfulWork).toBe(false)
|
||||
})
|
||||
|
||||
it("tool.execute sets hasReceivedMeaningfulWork", async () => {
|
||||
// #given
|
||||
const ctx = createMockContext("my-session")
|
||||
const state = createEventState()
|
||||
|
||||
const payload: EventPayload = {
|
||||
type: "tool.execute",
|
||||
properties: {
|
||||
sessionID: "my-session",
|
||||
name: "read_file",
|
||||
input: { filePath: "/src/index.ts" },
|
||||
},
|
||||
}
|
||||
|
||||
const events = toAsyncIterable([payload])
|
||||
const { processEvents } = await import("./events")
|
||||
|
||||
// #when
|
||||
await processEvents(ctx, events, state)
|
||||
|
||||
// #then
|
||||
expect(state.hasReceivedMeaningfulWork).toBe(true)
|
||||
})
|
||||
|
||||
it("tool.execute from different session does not set hasReceivedMeaningfulWork", async () => {
|
||||
// #given
|
||||
const ctx = createMockContext("my-session")
|
||||
const state = createEventState()
|
||||
|
||||
const payload: EventPayload = {
|
||||
type: "tool.execute",
|
||||
properties: {
|
||||
sessionID: "other-session",
|
||||
name: "read_file",
|
||||
input: { filePath: "/src/index.ts" },
|
||||
},
|
||||
}
|
||||
|
||||
const events = toAsyncIterable([payload])
|
||||
const { processEvents } = await import("./events")
|
||||
|
||||
// #when
|
||||
await processEvents(ctx, events, state)
|
||||
|
||||
// #then - different session's tool call shouldn't count
|
||||
expect(state.hasReceivedMeaningfulWork).toBe(false)
|
||||
})
|
||||
|
||||
it("session.status with busy type sets mainSessionIdle to false", async () => {
|
||||
// #given
|
||||
const ctx = createMockContext("my-session")
|
||||
@@ -136,6 +250,7 @@ describe("event handling", () => {
|
||||
lastOutput: "",
|
||||
lastPartText: "",
|
||||
currentTool: null,
|
||||
hasReceivedMeaningfulWork: false,
|
||||
}
|
||||
|
||||
const payload: EventPayload = {
|
||||
|
||||
@@ -63,6 +63,8 @@ export interface EventState {
|
||||
lastOutput: string
|
||||
lastPartText: string
|
||||
currentTool: string | null
|
||||
/** Set to true when the main session has produced meaningful work (text, tool call, or tool result) */
|
||||
hasReceivedMeaningfulWork: boolean
|
||||
}
|
||||
|
||||
export function createEventState(): EventState {
|
||||
@@ -73,6 +75,7 @@ export function createEventState(): EventState {
|
||||
lastOutput: "",
|
||||
lastPartText: "",
|
||||
currentTool: null,
|
||||
hasReceivedMeaningfulWork: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +116,9 @@ function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
|
||||
const isMainSession = sessionID === ctx.sessionID
|
||||
const sessionTag = isMainSession
|
||||
? pc.green("[MAIN]")
|
||||
: pc.yellow(`[${String(sessionID).slice(0, 8)}]`)
|
||||
: sessionID
|
||||
? pc.yellow(`[${String(sessionID).slice(0, 8)}]`)
|
||||
: pc.dim("[system]")
|
||||
|
||||
switch (payload.type) {
|
||||
case "session.idle":
|
||||
@@ -124,8 +129,6 @@ function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
|
||||
}
|
||||
|
||||
case "message.part.updated": {
|
||||
// Skip verbose logging for partial message updates
|
||||
// Only log tool invocation state changes, not text streaming
|
||||
const partProps = props as MessagePartUpdatedProps | undefined
|
||||
const part = partProps?.part
|
||||
if (part?.type === "tool-invocation") {
|
||||
@@ -133,6 +136,11 @@ function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
|
||||
console.error(
|
||||
pc.dim(`${sessionTag} message.part (tool): ${toolPart.toolName} [${toolPart.state}]`)
|
||||
)
|
||||
} else if (part?.type === "text" && part.text) {
|
||||
const preview = part.text.slice(0, 80).replace(/\n/g, "\\n")
|
||||
console.error(
|
||||
pc.dim(`${sessionTag} message.part (text): "${preview}${part.text.length > 80 ? "..." : ""}"`)
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -140,11 +148,10 @@ function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
|
||||
case "message.updated": {
|
||||
const msgProps = props as MessageUpdatedProps | undefined
|
||||
const role = msgProps?.info?.role ?? "unknown"
|
||||
const content = msgProps?.content ?? ""
|
||||
const preview = content.slice(0, 100).replace(/\n/g, "\\n")
|
||||
console.error(
|
||||
pc.dim(`${sessionTag} message.updated (${role}): "${preview}${content.length > 100 ? "..." : ""}"`)
|
||||
)
|
||||
const model = msgProps?.info?.modelID
|
||||
const agent = msgProps?.info?.agent
|
||||
const details = [role, agent, model].filter(Boolean).join(", ")
|
||||
console.error(pc.dim(`${sessionTag} message.updated (${details})`))
|
||||
break
|
||||
}
|
||||
|
||||
@@ -241,6 +248,7 @@ function handleMessagePartUpdated(
|
||||
const newText = part.text.slice(state.lastPartText.length)
|
||||
if (newText) {
|
||||
process.stdout.write(newText)
|
||||
state.hasReceivedMeaningfulWork = true
|
||||
}
|
||||
state.lastPartText = part.text
|
||||
}
|
||||
@@ -257,16 +265,7 @@ function handleMessageUpdated(
|
||||
if (props?.info?.sessionID !== ctx.sessionID) return
|
||||
if (props?.info?.role !== "assistant") return
|
||||
|
||||
const content = props.content
|
||||
if (!content || content === state.lastOutput) return
|
||||
|
||||
if (state.lastPartText.length === 0) {
|
||||
const newContent = content.slice(state.lastOutput.length)
|
||||
if (newContent) {
|
||||
process.stdout.write(newContent)
|
||||
}
|
||||
}
|
||||
state.lastOutput = content
|
||||
state.hasReceivedMeaningfulWork = true
|
||||
}
|
||||
|
||||
function handleToolExecute(
|
||||
@@ -296,6 +295,7 @@ function handleToolExecute(
|
||||
}
|
||||
}
|
||||
|
||||
state.hasReceivedMeaningfulWork = true
|
||||
process.stdout.write(`\n${pc.cyan(">")} ${pc.bold(toolName)}${inputPreview}\n`)
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,14 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Guard against premature completion: don't check completion until the
|
||||
// session has produced meaningful work (text output, tool call, or tool result).
|
||||
// Without this, a session that goes busy->idle before the LLM responds
|
||||
// would exit immediately because 0 todos + 0 children = "complete".
|
||||
if (!eventState.hasReceivedMeaningfulWork) {
|
||||
continue
|
||||
}
|
||||
|
||||
const shouldExit = await checkCompletionConditions(ctx)
|
||||
if (shouldExit) {
|
||||
console.log(pc.green("\n\nAll tasks completed."))
|
||||
|
||||
@@ -44,8 +44,13 @@ export interface SessionStatusProps {
|
||||
}
|
||||
|
||||
export interface MessageUpdatedProps {
|
||||
info?: { sessionID?: string; role?: string }
|
||||
content?: string
|
||||
info?: {
|
||||
sessionID?: string
|
||||
role?: string
|
||||
modelID?: string
|
||||
providerID?: string
|
||||
agent?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface MessagePartUpdatedProps {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, test, expect, mock, beforeEach } from 'bun:test'
|
||||
import type { TmuxConfig } from '../../config/schema'
|
||||
import type { WindowState, PaneAction } from './types'
|
||||
import type { ActionResult, ExecuteContext } from './action-executor'
|
||||
import type { TmuxUtilDeps } from './manager'
|
||||
|
||||
type ExecuteActionsResult = {
|
||||
success: boolean
|
||||
@@ -33,6 +34,11 @@ const mockExecuteAction = mock<(
|
||||
const mockIsInsideTmux = mock<() => boolean>(() => true)
|
||||
const mockGetCurrentPaneId = mock<() => string | undefined>(() => '%0')
|
||||
|
||||
const mockTmuxDeps: TmuxUtilDeps = {
|
||||
isInsideTmux: mockIsInsideTmux,
|
||||
getCurrentPaneId: mockGetCurrentPaneId,
|
||||
}
|
||||
|
||||
mock.module('./pane-state-querier', () => ({
|
||||
queryWindowState: mockQueryWindowState,
|
||||
paneExists: mockPaneExists,
|
||||
@@ -51,15 +57,19 @@ mock.module('./action-executor', () => ({
|
||||
executeAction: mockExecuteAction,
|
||||
}))
|
||||
|
||||
mock.module('../../shared/tmux', () => ({
|
||||
isInsideTmux: mockIsInsideTmux,
|
||||
getCurrentPaneId: mockGetCurrentPaneId,
|
||||
POLL_INTERVAL_BACKGROUND_MS: 2000,
|
||||
SESSION_TIMEOUT_MS: 600000,
|
||||
SESSION_MISSING_GRACE_MS: 6000,
|
||||
SESSION_READY_POLL_INTERVAL_MS: 100,
|
||||
SESSION_READY_TIMEOUT_MS: 500,
|
||||
}))
|
||||
mock.module('../../shared/tmux', () => {
|
||||
const { isInsideTmux, getCurrentPaneId } = require('../../shared/tmux/tmux-utils')
|
||||
const { POLL_INTERVAL_BACKGROUND_MS, SESSION_TIMEOUT_MS, SESSION_MISSING_GRACE_MS } = require('../../shared/tmux/constants')
|
||||
return {
|
||||
isInsideTmux,
|
||||
getCurrentPaneId,
|
||||
POLL_INTERVAL_BACKGROUND_MS,
|
||||
SESSION_TIMEOUT_MS,
|
||||
SESSION_MISSING_GRACE_MS,
|
||||
SESSION_READY_POLL_INTERVAL_MS: 100,
|
||||
SESSION_READY_TIMEOUT_MS: 500,
|
||||
}
|
||||
})
|
||||
|
||||
const trackedSessions = new Set<string>()
|
||||
|
||||
@@ -148,7 +158,7 @@ describe('TmuxSessionManager', () => {
|
||||
}
|
||||
|
||||
//#when
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#then
|
||||
expect(manager).toBeDefined()
|
||||
@@ -168,7 +178,7 @@ describe('TmuxSessionManager', () => {
|
||||
}
|
||||
|
||||
//#when
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#then
|
||||
expect(manager).toBeDefined()
|
||||
@@ -188,7 +198,7 @@ describe('TmuxSessionManager', () => {
|
||||
}
|
||||
|
||||
//#when
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#then
|
||||
expect(manager).toBeDefined()
|
||||
@@ -210,7 +220,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
const event = createSessionCreatedEvent(
|
||||
'ses_child',
|
||||
'ses_parent',
|
||||
@@ -271,7 +281,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#when - first agent
|
||||
await manager.onSessionCreated(
|
||||
@@ -305,7 +315,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
const event = createSessionCreatedEvent('ses_root', undefined, 'Root Session')
|
||||
|
||||
//#when
|
||||
@@ -327,7 +337,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
const event = createSessionCreatedEvent(
|
||||
'ses_child',
|
||||
'ses_parent',
|
||||
@@ -353,7 +363,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
const event = {
|
||||
type: 'session.deleted',
|
||||
properties: {
|
||||
@@ -398,7 +408,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 120,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#when
|
||||
await manager.onSessionCreated(
|
||||
@@ -450,7 +460,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
await manager.onSessionCreated(
|
||||
createSessionCreatedEvent(
|
||||
@@ -487,7 +497,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#when
|
||||
await manager.onSessionDeleted({ sessionID: 'ses_unknown' })
|
||||
@@ -521,7 +531,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
await manager.onSessionCreated(
|
||||
createSessionCreatedEvent('ses_1', 'ses_parent', 'Task 1')
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { TmuxConfig } from "../../config/schema"
|
||||
import type { TrackedSession, CapacityConfig } from "./types"
|
||||
import {
|
||||
isInsideTmux,
|
||||
getCurrentPaneId,
|
||||
isInsideTmux as defaultIsInsideTmux,
|
||||
getCurrentPaneId as defaultGetCurrentPaneId,
|
||||
POLL_INTERVAL_BACKGROUND_MS,
|
||||
SESSION_MISSING_GRACE_MS,
|
||||
SESSION_READY_POLL_INTERVAL_MS,
|
||||
@@ -21,6 +21,16 @@ interface SessionCreatedEvent {
|
||||
properties?: { info?: { id?: string; parentID?: string; title?: string } }
|
||||
}
|
||||
|
||||
export interface TmuxUtilDeps {
|
||||
isInsideTmux: () => boolean
|
||||
getCurrentPaneId: () => string | undefined
|
||||
}
|
||||
|
||||
const defaultTmuxDeps: TmuxUtilDeps = {
|
||||
isInsideTmux: defaultIsInsideTmux,
|
||||
getCurrentPaneId: defaultGetCurrentPaneId,
|
||||
}
|
||||
|
||||
const SESSION_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
/**
|
||||
@@ -43,13 +53,15 @@ export class TmuxSessionManager {
|
||||
private sessions = new Map<string, TrackedSession>()
|
||||
private pendingSessions = new Set<string>()
|
||||
private pollInterval?: ReturnType<typeof setInterval>
|
||||
private deps: TmuxUtilDeps
|
||||
|
||||
constructor(ctx: PluginInput, tmuxConfig: TmuxConfig) {
|
||||
constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: TmuxUtilDeps = defaultTmuxDeps) {
|
||||
this.client = ctx.client
|
||||
this.tmuxConfig = tmuxConfig
|
||||
this.deps = deps
|
||||
const defaultPort = process.env.OPENCODE_PORT ?? "4096"
|
||||
this.serverUrl = ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}`
|
||||
this.sourcePaneId = getCurrentPaneId()
|
||||
this.sourcePaneId = deps.getCurrentPaneId()
|
||||
|
||||
log("[tmux-session-manager] initialized", {
|
||||
configEnabled: this.tmuxConfig.enabled,
|
||||
@@ -60,7 +72,7 @@ export class TmuxSessionManager {
|
||||
}
|
||||
|
||||
private isEnabled(): boolean {
|
||||
return this.tmuxConfig.enabled && isInsideTmux()
|
||||
return this.tmuxConfig.enabled && this.deps.isInsideTmux()
|
||||
}
|
||||
|
||||
private getCapacityConfig(): CapacityConfig {
|
||||
@@ -113,7 +125,7 @@ export class TmuxSessionManager {
|
||||
log("[tmux-session-manager] onSessionCreated called", {
|
||||
enabled,
|
||||
tmuxConfigEnabled: this.tmuxConfig.enabled,
|
||||
isInsideTmux: isInsideTmux(),
|
||||
isInsideTmux: this.deps.isInsideTmux(),
|
||||
eventType: event.type,
|
||||
infoId: event.properties?.info?.id,
|
||||
infoParentID: event.properties?.info?.parentID,
|
||||
|
||||
@@ -1474,6 +1474,73 @@ describe("sisyphus-task", () => {
|
||||
}, { timeout: 20000 })
|
||||
})
|
||||
|
||||
describe("category model resolution fallback", () => {
|
||||
test("category uses resolved.model when connectedProvidersCache is null and availableModels is empty", async () => {
|
||||
// #given - connectedProvidersCache returns null (simulates missing cache file)
|
||||
// This is a regression test for PR #1227 which removed resolved.model from userModel chain
|
||||
cacheSpy.mockReturnValue(null)
|
||||
|
||||
const { createDelegateTask } = require("./tools")
|
||||
let launchInput: any
|
||||
|
||||
const mockManager = {
|
||||
launch: async (input: any) => {
|
||||
launchInput = input
|
||||
return {
|
||||
id: "task-fallback",
|
||||
sessionID: "ses_fallback_test",
|
||||
description: "Fallback test task",
|
||||
agent: "sisyphus-junior",
|
||||
status: "running",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
model: { list: async () => [] },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
// NO userCategories override, NO sisyphusJuniorModel
|
||||
const tool = createDelegateTask({
|
||||
manager: mockManager,
|
||||
client: mockClient,
|
||||
// userCategories: undefined - use DEFAULT_CATEGORIES only
|
||||
// sisyphusJuniorModel: undefined
|
||||
})
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// #when - using "quick" category which should use "anthropic/claude-haiku-4-5"
|
||||
await tool.execute(
|
||||
{
|
||||
description: "Test category fallback",
|
||||
prompt: "Do something quick",
|
||||
category: "quick",
|
||||
run_in_background: true,
|
||||
load_skills: [],
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then - model should be anthropic/claude-haiku-4-5 from DEFAULT_CATEGORIES
|
||||
// NOT anthropic/claude-sonnet-4-5 (system default)
|
||||
expect(launchInput.model.providerID).toBe("anthropic")
|
||||
expect(launchInput.model.modelID).toBe("claude-haiku-4-5")
|
||||
})
|
||||
})
|
||||
|
||||
describe("browserProvider propagation", () => {
|
||||
test("should resolve agent-browser skill when browserProvider is passed", async () => {
|
||||
// #given - delegate_task configured with browserProvider: "agent-browser"
|
||||
|
||||
@@ -541,7 +541,7 @@ To continue this session: session_id="${args.session_id}"`
|
||||
}
|
||||
} else {
|
||||
const resolution = resolveModelWithFallback({
|
||||
userModel: userCategories?.[args.category]?.model ?? sisyphusJuniorModel,
|
||||
userModel: userCategories?.[args.category]?.model ?? resolved.model ?? sisyphusJuniorModel,
|
||||
fallbackChain: requirement.fallbackChain,
|
||||
availableModels,
|
||||
systemDefaultModel,
|
||||
|
||||
Reference in New Issue
Block a user