feat: add ci test runner, session routing, bash parser, and test fixtures
- script/run-ci-tests.ts: CI test sharding and isolation logic - script/run-ci-tests.test.ts: tests for CI test target selection - src/features/background-agent/session-route.ts: session prompt routing for background agents - src/hooks/interactive-bash-session/parser.ts: interactive bash output parser - src/hooks/ralph-loop/completion-promise-detector-test-input.ts: test fixture for completion promise detection
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { promptWithModelSuggestionRetry } from "../../shared"
|
||||
import { promptAsyncAfterSessionIdle } from "../../shared/prompt-async-gate"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
type PromptAsyncArgs = Parameters<OpencodeClient["session"]["promptAsync"]>[0]
|
||||
type PromptRetryClient = Parameters<typeof promptWithModelSuggestionRetry>[0]
|
||||
type PromptRetryArgs = Parameters<typeof promptWithModelSuggestionRetry>[1]
|
||||
type SessionMessagesArgs = Parameters<OpencodeClient["session"]["messages"]>[0]
|
||||
|
||||
export function routeSessionPrompt(args: PromptAsyncArgs, directory: string): PromptAsyncArgs {
|
||||
return {
|
||||
...args,
|
||||
query: { directory },
|
||||
}
|
||||
}
|
||||
|
||||
function routePromptRetry(args: PromptRetryArgs, directory: string): PromptRetryArgs {
|
||||
return {
|
||||
...args,
|
||||
query: { directory },
|
||||
}
|
||||
}
|
||||
|
||||
export function promptAsyncInDirectory(
|
||||
client: OpencodeClient,
|
||||
args: PromptAsyncArgs,
|
||||
directory: string,
|
||||
): Promise<unknown> {
|
||||
const routedArgs = routeSessionPrompt(args, directory)
|
||||
const sessionID = routedArgs.path?.id
|
||||
if (!sessionID) {
|
||||
return Promise.reject(new Error("session id is required for routed promptAsync"))
|
||||
}
|
||||
|
||||
return promptAsyncAfterSessionIdle({
|
||||
client,
|
||||
sessionID,
|
||||
input: routedArgs,
|
||||
source: "background-agent-session-route",
|
||||
settleMs: 0,
|
||||
}).then((result) => {
|
||||
if (result.status === "failed") {
|
||||
throw result.error
|
||||
}
|
||||
if (result.status !== "dispatched") {
|
||||
throw new Error(`promptAsync skipped by gate: ${result.status}`)
|
||||
}
|
||||
return result.response
|
||||
})
|
||||
}
|
||||
|
||||
export function promptWithRetryInDirectory(
|
||||
client: PromptRetryClient,
|
||||
args: PromptRetryArgs,
|
||||
directory: string,
|
||||
): Promise<void> {
|
||||
return promptWithModelSuggestionRetry(client, routePromptRetry(args, directory))
|
||||
}
|
||||
|
||||
export function messagesInDirectory(
|
||||
client: OpencodeClient,
|
||||
args: SessionMessagesArgs,
|
||||
directory: string,
|
||||
): Promise<unknown> {
|
||||
return client.session.messages({
|
||||
...args,
|
||||
query: { directory },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Quote-aware command tokenizer with escape handling
|
||||
* Handles single/double quotes and backslash escapes
|
||||
*/
|
||||
export function tokenizeCommand(cmd: string): string[] {
|
||||
const tokens: string[] = []
|
||||
let current = ""
|
||||
let inQuote = false
|
||||
let quoteChar = ""
|
||||
let escaped = false
|
||||
|
||||
for (let i = 0; i < cmd.length; i++) {
|
||||
const char = cmd[i]
|
||||
|
||||
if (escaped) {
|
||||
current += char
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "\\") {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
|
||||
if ((char === "'" || char === '"') && !inQuote) {
|
||||
inQuote = true
|
||||
quoteChar = char
|
||||
} else if (char === quoteChar && inQuote) {
|
||||
inQuote = false
|
||||
quoteChar = ""
|
||||
} else if (char === " " && !inQuote) {
|
||||
if (current) {
|
||||
tokens.push(current)
|
||||
current = ""
|
||||
}
|
||||
} else {
|
||||
current += char
|
||||
}
|
||||
}
|
||||
|
||||
if (current) tokens.push(current)
|
||||
return tokens
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize session name by stripping :window and .pane suffixes
|
||||
* e.g., "omo-x:1" -> "omo-x", "omo-x:1.2" -> "omo-x"
|
||||
*/
|
||||
export function normalizeSessionName(name: string): string {
|
||||
return name.split(":")[0].split(".")[0]
|
||||
}
|
||||
|
||||
export function findFlagValue(tokens: string[], flag: string): string | null {
|
||||
for (let i = 0; i < tokens.length - 1; i++) {
|
||||
if (tokens[i] === flag) return tokens[i + 1]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract session name from tokens, considering the subCommand
|
||||
* For new-session: prioritize -s over -t
|
||||
* For other commands: use -t
|
||||
*/
|
||||
export function extractSessionNameFromTokens(tokens: string[], subCommand: string): string | null {
|
||||
if (subCommand === "new-session") {
|
||||
const sFlag = findFlagValue(tokens, "-s")
|
||||
if (sFlag) return normalizeSessionName(sFlag)
|
||||
const tFlag = findFlagValue(tokens, "-t")
|
||||
if (tFlag) return normalizeSessionName(tFlag)
|
||||
} else {
|
||||
const tFlag = findFlagValue(tokens, "-t")
|
||||
if (tFlag) return normalizeSessionName(tFlag)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the tmux subcommand from tokens, skipping global options.
|
||||
* tmux allows global options before the subcommand:
|
||||
* e.g., `tmux -L socket-name new-session -s omo-x`
|
||||
* Global options with args: -L, -S, -f, -c, -T
|
||||
* Standalone flags: -C, -v, -V, etc.
|
||||
* Special: -- (end of options marker)
|
||||
*/
|
||||
export function findSubcommand(tokens: string[]): string {
|
||||
// Options that require an argument: -L, -S, -f, -c, -T
|
||||
const globalOptionsWithArgs = new Set(["-L", "-S", "-f", "-c", "-T"])
|
||||
|
||||
let i = 0
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i]
|
||||
|
||||
// Handle end of options marker
|
||||
if (token === "--") {
|
||||
// Next token is the subcommand
|
||||
return tokens[i + 1] ?? ""
|
||||
}
|
||||
|
||||
if (globalOptionsWithArgs.has(token)) {
|
||||
// Skip the option and its argument
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
|
||||
if (token.startsWith("-")) {
|
||||
// Skip standalone flags like -C, -v, -V
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Found the subcommand
|
||||
return token
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/// <reference types="bun-types" />
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
export type SessionMessage = {
|
||||
info?: { role?: string }
|
||||
parts?: Array<{ type: string; text?: string }>
|
||||
}
|
||||
|
||||
export function createPluginInput(messages: SessionMessage[]): PluginInput {
|
||||
const pluginInput = {
|
||||
client: { session: {} } as PluginInput["client"],
|
||||
project: {} as PluginInput["project"],
|
||||
directory: "/tmp",
|
||||
worktree: "/tmp",
|
||||
serverUrl: new URL("http://localhost"),
|
||||
$: {} as PluginInput["$"],
|
||||
} as PluginInput
|
||||
|
||||
pluginInput.client.session.messages =
|
||||
(async () => ({ data: messages })) as unknown as PluginInput["client"]["session"]["messages"]
|
||||
|
||||
return pluginInput
|
||||
}
|
||||
Reference in New Issue
Block a user