mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-01 16:03:19 -04:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2212702735 | |||
| fb76b5ab4b | |||
| bc2cfbe065 | |||
| f00fa4d760 | |||
| 086697d46c | |||
| 1e9a93de32 | |||
| daa4389126 | |||
| e2aa1b43b9 | |||
| 73f495313c | |||
| 587238abf9 | |||
| 3b97ec0f47 | |||
| bd18aebca6 | |||
| e8a0a413e6 | |||
| 5030097b55 | |||
| 9a72c4e337 |
@@ -6,6 +6,12 @@ on:
|
||||
- dev
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
windows_opentui_repro:
|
||||
description: "Run targeted Windows OpenTUI standalone crash repro"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
# Keep every run on dev so cancelled checks do not pollute the default branch
|
||||
@@ -22,6 +28,7 @@ env:
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.windows_opentui_repro != true }}
|
||||
name: unit (${{ matrix.settings.name }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -80,6 +87,7 @@ jobs:
|
||||
run: bun run test:httpapi
|
||||
|
||||
e2e:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.windows_opentui_repro != true }}
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -149,3 +157,85 @@ jobs:
|
||||
path: |
|
||||
packages/app/e2e/test-results
|
||||
packages/app/e2e/playwright-report
|
||||
|
||||
windows-opentui-repro:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.windows_opentui_repro == true }}
|
||||
name: windows opentui repro (${{ matrix.name }}, ${{ matrix.version }}, ${{ matrix.host }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- version: "1.17.9"
|
||||
host: windows-latest
|
||||
name: baseline
|
||||
markdownLoops: 1
|
||||
onlyMarkdown: false
|
||||
- version: "1.17.9"
|
||||
host: windows-latest
|
||||
name: markdown-stress
|
||||
markdownLoops: 10
|
||||
onlyMarkdown: true
|
||||
- version: "1.17.10"
|
||||
host: windows-latest
|
||||
name: baseline
|
||||
markdownLoops: 1
|
||||
onlyMarkdown: false
|
||||
- version: "1.17.10"
|
||||
host: windows-latest
|
||||
name: markdown-stress
|
||||
markdownLoops: 10
|
||||
onlyMarkdown: true
|
||||
- version: "1.17.11"
|
||||
host: windows-latest
|
||||
name: baseline
|
||||
markdownLoops: 1
|
||||
onlyMarkdown: false
|
||||
- version: "1.17.11"
|
||||
host: windows-latest
|
||||
name: markdown-stress
|
||||
markdownLoops: 10
|
||||
onlyMarkdown: true
|
||||
- version: "1.17.10"
|
||||
host: windows-2022
|
||||
name: baseline
|
||||
markdownLoops: 1
|
||||
onlyMarkdown: false
|
||||
runs-on: ${{ matrix.host }}
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "24.11.1"
|
||||
|
||||
- name: Run Windows standalone repro
|
||||
timeout-minutes: 30
|
||||
run: |
|
||||
$reproArgs = @{
|
||||
Version = "${{ matrix.version }}"
|
||||
MarkdownLoops = ${{ matrix.markdownLoops }}
|
||||
}
|
||||
if ("${{ matrix.onlyMarkdown }}" -eq "true") {
|
||||
$reproArgs.OnlyMarkdown = $true
|
||||
}
|
||||
./script/repro-windows-opentui-crash.ps1 @reproArgs
|
||||
|
||||
- name: Upload Windows repro artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: windows-opentui-repro-${{ matrix.name }}-${{ matrix.version }}-${{ matrix.host }}-${{ github.run_attempt }}
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
path: |
|
||||
${{ runner.temp }}/opencode-windows-opentui-${{ matrix.version }}/**/*.log
|
||||
${{ runner.temp }}/opencode-windows-opentui-${{ matrix.version }}/**/*.jsonl
|
||||
${{ runner.temp }}/*.out.log
|
||||
${{ runner.temp }}/*.err.log
|
||||
|
||||
@@ -1528,7 +1528,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.error}
|
||||
>
|
||||
<text fg={theme.textMuted}>{props.message.error?.data.message}</text>
|
||||
<text fg={theme.textMuted}>{String(props.message.error?.data.message ?? "")}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Switch>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useBindings, useCommandShortcut } from "../keymap"
|
||||
|
||||
export type DialogPromptProps = {
|
||||
title: string
|
||||
description?: () => JSX.Element
|
||||
description?: JSX.Element | (() => JSX.Element)
|
||||
placeholder?: string
|
||||
value?: string
|
||||
busy?: boolean
|
||||
@@ -25,6 +25,11 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||
const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
|
||||
let textarea: TextareaRenderable
|
||||
|
||||
const description = () => {
|
||||
if (typeof props.description === "function") return props.description()
|
||||
return props.description
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (props.busy) return
|
||||
props.onConfirm?.(textarea.plainText)
|
||||
@@ -83,7 +88,7 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||
</text>
|
||||
</box>
|
||||
<box gap={1}>
|
||||
{props.description}
|
||||
{description()}
|
||||
<textarea
|
||||
height={3}
|
||||
ref={(val: TextareaRenderable) => {
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Version,
|
||||
[int]$McpLoops = 0,
|
||||
[int]$McpServers = 20,
|
||||
[int]$MarkdownLoops = 1,
|
||||
[int]$StartupSeconds = 20,
|
||||
[int]$PtySeconds = 120,
|
||||
[switch]$OnlyMarkdown
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Write-Section([string]$Name) {
|
||||
Write-Host ""
|
||||
Write-Host "==== $Name ===="
|
||||
}
|
||||
|
||||
function Read-TextFile([string]$Path) {
|
||||
if (!(Test-Path $Path)) {
|
||||
return ""
|
||||
}
|
||||
return [System.IO.File]::ReadAllText($Path)
|
||||
}
|
||||
|
||||
function Test-CrashText([string]$Text) {
|
||||
if ($Text -match "Segmentation fault") { return $true }
|
||||
if ($Text -match "Bun has crashed") { return $true }
|
||||
if ($Text -match "ACCESS_VIOLATION") { return $true }
|
||||
if ($Text -match "0xC0000005") { return $true }
|
||||
if ($Text -match "322122") { return $true }
|
||||
if ($Text -match "panic\(main thread\)") { return $true }
|
||||
return $false
|
||||
}
|
||||
|
||||
function Invoke-Opencode {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Label,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Exe,
|
||||
[string[]]$Args = @(),
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$WorkingDirectory,
|
||||
[int]$TimeoutSeconds = 60,
|
||||
[switch]$AllowNonZero,
|
||||
[switch]$AllowTimeout
|
||||
)
|
||||
|
||||
Write-Section $Label
|
||||
Write-Host "cwd=$WorkingDirectory"
|
||||
Write-Host "cmd=$Exe $($Args -join ' ')"
|
||||
|
||||
$safe = ($Label -replace "[^A-Za-z0-9_.-]", "_")
|
||||
$stdout = Join-Path $env:RUNNER_TEMP "$safe.out.log"
|
||||
$stderr = Join-Path $env:RUNNER_TEMP "$safe.err.log"
|
||||
Remove-Item -ErrorAction SilentlyContinue $stdout, $stderr
|
||||
|
||||
$process = Start-Process -FilePath $Exe -ArgumentList $Args -WorkingDirectory $WorkingDirectory -NoNewWindow -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr
|
||||
$exited = $process.WaitForExit($TimeoutSeconds * 1000)
|
||||
if (!$exited) {
|
||||
Write-Host "timeout after ${TimeoutSeconds}s; terminating process id $($process.Id)"
|
||||
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
|
||||
$outText = Read-TextFile $stdout
|
||||
$errText = Read-TextFile $stderr
|
||||
if ($outText.Length -gt 0) {
|
||||
Write-Host "--- stdout ---"
|
||||
Write-Host $outText
|
||||
}
|
||||
if ($errText.Length -gt 0) {
|
||||
Write-Host "--- stderr ---"
|
||||
Write-Host $errText
|
||||
}
|
||||
|
||||
$combined = $outText + "`n" + $errText
|
||||
if (Test-CrashText $combined) {
|
||||
throw "native crash signature detected in $Label"
|
||||
}
|
||||
|
||||
if (!$exited) {
|
||||
if ($AllowTimeout) {
|
||||
Write-Host "timeout accepted for long-running TUI startup check"
|
||||
return
|
||||
}
|
||||
throw "timeout in $Label"
|
||||
}
|
||||
|
||||
$exitCode = [int64]$process.ExitCode
|
||||
Write-Host "exitCode=$exitCode"
|
||||
if (@(3, -1073741819, -1073740791, -1073741571) -contains $exitCode) {
|
||||
throw "native crash exit code $exitCode in $Label"
|
||||
}
|
||||
if (!$AllowNonZero -and $process.ExitCode -ne 0) {
|
||||
throw "unexpected exit code $($process.ExitCode) in $Label"
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PtyScenario {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Scenario,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Exe,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Project,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Version,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$Seconds
|
||||
)
|
||||
|
||||
node (Join-Path $PSScriptRoot "repro-windows-opentui-pty-session.mjs") -- --exe $Exe --project $Project --version $Version --seconds $Seconds --scenario $Scenario
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$script:PtyFailures += "PTY scenario '$Scenario' failed with exit code $LASTEXITCODE"
|
||||
Write-Host "::error::$($script:PtyFailures[-1])"
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-MarkdownScenarios {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Exe,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Project,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Version,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$Seconds,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$Loops
|
||||
)
|
||||
|
||||
for ($i = 1; $i -le $Loops; $i++) {
|
||||
$suffix = if ($Loops -gt 1) { "-$($i.ToString('00'))" } else { "" }
|
||||
if ($Version -eq "1.17.10") {
|
||||
Invoke-PtyScenario -Scenario "markdown-no-native-render$suffix" -Exe $Exe -Project $Project -Version $Version -Seconds $Seconds
|
||||
}
|
||||
Invoke-PtyScenario -Scenario "markdown$suffix" -Exe $Exe -Project $Project -Version $Version -Seconds $Seconds
|
||||
}
|
||||
}
|
||||
|
||||
Write-Section "Environment"
|
||||
node --version
|
||||
npm --version
|
||||
Write-Host "RUNNER_OS=$env:RUNNER_OS RUNNER_ARCH=$env:RUNNER_ARCH"
|
||||
Write-Host "PROCESSOR_ARCHITECTURE=$env:PROCESSOR_ARCHITECTURE"
|
||||
Write-Host "MarkdownLoops=$MarkdownLoops OnlyMarkdown=$OnlyMarkdown"
|
||||
if ($MarkdownLoops -lt 1) {
|
||||
throw "MarkdownLoops must be >= 1"
|
||||
}
|
||||
|
||||
Write-Section "Install opencode-ai@$Version"
|
||||
npm uninstall -g opencode-ai opencode-windows-x64 2>$null | Out-Host
|
||||
npm install -g "opencode-ai@$Version"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "npm install opencode-ai@$Version failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
$npmRoot = (npm root -g).Trim()
|
||||
$exe = Join-Path $npmRoot "opencode-ai\bin\opencode.exe"
|
||||
if (!(Test-Path $exe)) {
|
||||
$cmd = Get-Command opencode -ErrorAction Stop
|
||||
$exe = $cmd.Source
|
||||
}
|
||||
Write-Host "opencode executable=$exe"
|
||||
if (Test-Path $exe) {
|
||||
$info = (Get-Item $exe).VersionInfo
|
||||
Write-Host "FileDescription=$($info.FileDescription) FileVersion=$($info.FileVersion) ProductVersion=$($info.ProductVersion) CompanyName=$($info.CompanyName)"
|
||||
}
|
||||
|
||||
$root = Join-Path $env:RUNNER_TEMP "opencode-windows-opentui-$Version"
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $root
|
||||
New-Item -ItemType Directory -Force $root | Out-Null
|
||||
$env:XDG_CONFIG_HOME = Join-Path $root "xdg-config"
|
||||
$env:XDG_DATA_HOME = Join-Path $root "xdg-data"
|
||||
$env:XDG_CACHE_HOME = Join-Path $root "xdg-cache"
|
||||
$env:XDG_STATE_HOME = Join-Path $root "xdg-state"
|
||||
$env:APPDATA = Join-Path $root "appdata"
|
||||
$env:LOCALAPPDATA = Join-Path $root "localappdata"
|
||||
$env:OPENCODE_DISABLE_AUTOUPDATE = "1"
|
||||
$env:OPENCODE_DISABLE_EXTERNAL_SKILLS = "1"
|
||||
$env:OPENCODE_DISABLE_LSP_DOWNLOAD = "1"
|
||||
$env:OPENCODE_PURE = "1"
|
||||
New-Item -ItemType Directory -Force $env:XDG_CONFIG_HOME, $env:XDG_DATA_HOME, $env:XDG_CACHE_HOME, $env:XDG_STATE_HOME, $env:APPDATA, $env:LOCALAPPDATA | Out-Null
|
||||
|
||||
$emptyProject = Join-Path $root "empty-project"
|
||||
$mcpProject = Join-Path $root "mcp-project"
|
||||
$sessionProject = Join-Path $root "session-project"
|
||||
New-Item -ItemType Directory -Force $emptyProject, $mcpProject, $sessionProject | Out-Null
|
||||
|
||||
Invoke-Opencode -Label "version" -Exe $exe -Args @("--version") -WorkingDirectory $emptyProject -TimeoutSeconds 30
|
||||
Invoke-Opencode -Label "help" -Exe $exe -Args @("--help") -WorkingDirectory $emptyProject -TimeoutSeconds 30
|
||||
|
||||
Write-Section "Write MCP spawn-storm config"
|
||||
$servers = [ordered]@{}
|
||||
for ($i = 1; $i -le $McpServers; $i++) {
|
||||
$name = "server$($i.ToString('00'))"
|
||||
$servers[$name] = [ordered]@{
|
||||
type = "local"
|
||||
command = @("node", "-e", "setTimeout(() => {}, 30000)")
|
||||
timeout = 350
|
||||
}
|
||||
}
|
||||
$config = [ordered]@{
|
||||
autoupdate = $false
|
||||
mcp = $servers
|
||||
}
|
||||
($config | ConvertTo-Json -Depth 10) | Set-Content -Encoding UTF8 (Join-Path $mcpProject "opencode.json")
|
||||
Get-Content (Join-Path $mcpProject "opencode.json") | Write-Host
|
||||
|
||||
for ($i = 1; $i -le $McpLoops; $i++) {
|
||||
Invoke-Opencode -Label "mcp-list-$i" -Exe $exe -Args @("mcp", "list") -WorkingDirectory $mcpProject -TimeoutSeconds 90 -AllowNonZero
|
||||
}
|
||||
|
||||
Invoke-Opencode -Label "empty-startup-tui" -Exe $exe -Args @() -WorkingDirectory $emptyProject -TimeoutSeconds $StartupSeconds -AllowTimeout -AllowNonZero
|
||||
|
||||
Write-Section "Install PTY dependency"
|
||||
$ptyRoot = Join-Path $root "pty-harness"
|
||||
New-Item -ItemType Directory -Force $ptyRoot | Out-Null
|
||||
Push-Location $ptyRoot
|
||||
try {
|
||||
npm init -y | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "npm init for PTY harness failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
npm install "@lydell/node-pty@1.2.0-beta.12" | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "npm install @lydell/node-pty failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
$script:PtyFailures = @()
|
||||
if ($OnlyMarkdown) {
|
||||
Invoke-MarkdownScenarios -Exe $exe -Project $sessionProject -Version $Version -Seconds $PtySeconds -Loops $MarkdownLoops
|
||||
} else {
|
||||
Invoke-PtyScenario -Scenario "text" -Exe $exe -Project $sessionProject -Version $Version -Seconds $PtySeconds
|
||||
Invoke-MarkdownScenarios -Exe $exe -Project $sessionProject -Version $Version -Seconds $PtySeconds -Loops $MarkdownLoops
|
||||
Invoke-PtyScenario -Scenario "bash-permission" -Exe $exe -Project $sessionProject -Version $Version -Seconds $PtySeconds
|
||||
Invoke-PtyScenario -Scenario "task-permission" -Exe $exe -Project $sessionProject -Version $Version -Seconds $PtySeconds
|
||||
Invoke-PtyScenario -Scenario "mcp-npx" -Exe $exe -Project $sessionProject -Version $Version -Seconds $PtySeconds
|
||||
if ($Version -eq "1.17.10") {
|
||||
Invoke-PtyScenario -Scenario "mcp-npx-no-native-render" -Exe $exe -Project $sessionProject -Version $Version -Seconds $PtySeconds
|
||||
}
|
||||
}
|
||||
if ($script:PtyFailures.Count -gt 0) {
|
||||
Write-Section "PTY Failures"
|
||||
$script:PtyFailures | ForEach-Object { Write-Host $_ }
|
||||
throw "$($script:PtyFailures.Count) PTY scenario(s) failed"
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Write-Section "Result"
|
||||
Write-Host "No native crash signature detected for opencode-ai@$Version"
|
||||
@@ -0,0 +1,447 @@
|
||||
import { createRequire } from "node:module"
|
||||
import { createServer } from "node:http"
|
||||
import { mkdirSync, writeFileSync } from "node:fs"
|
||||
import { join, resolve } from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { setTimeout as delay } from "node:timers/promises"
|
||||
|
||||
const require = createRequire(join(process.cwd(), "pty-harness.cjs"))
|
||||
const pty = require("@lydell/node-pty")
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const value = (name, fallback) => {
|
||||
const index = args.indexOf(name)
|
||||
if (index === -1) return fallback
|
||||
return args[index + 1]
|
||||
}
|
||||
|
||||
const exe = value("--exe")
|
||||
const project = value("--project")
|
||||
const version = value("--version")
|
||||
const seconds = Number(value("--seconds", "120"))
|
||||
const scenario = value("--scenario", "text")
|
||||
|
||||
if (!exe || !project || !version) {
|
||||
throw new Error("Usage: repro-windows-opentui-pty-session.mjs -- --exe <path> --project <path> --version <version>")
|
||||
}
|
||||
|
||||
const crashPattern = /Segmentation fault|Bun has crashed|ACCESS_VIOLATION|0xC0000005|322122|panic\(main thread\)/i
|
||||
const outputFile = join(project, `pty-output-${version}-${scenario}.log`)
|
||||
const requestsFile = join(project, `provider-requests-${version}-${scenario}.jsonl`)
|
||||
let output = ""
|
||||
let providerRequests = 0
|
||||
let exited = false
|
||||
let exitCode = undefined
|
||||
let exitSignal = undefined
|
||||
let permissionSubmitted = false
|
||||
let toolCallSent = false
|
||||
|
||||
function writeMcpStubPackage() {
|
||||
const stubDir = join(project, "mcp-stub-package")
|
||||
const binDir = join(stubDir, "bin")
|
||||
mkdirSync(binDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(stubDir, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "opencode-mcp-stub",
|
||||
version: "1.0.0",
|
||||
type: "module",
|
||||
bin: { "opencode-mcp-stub": "./bin/opencode-mcp-stub.mjs" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
)
|
||||
writeFileSync(
|
||||
join(binDir, "opencode-mcp-stub.mjs"),
|
||||
`#!/usr/bin/env node
|
||||
const idArg = process.argv.indexOf("--id")
|
||||
const id = idArg === -1 ? "server" : process.argv[idArg + 1]
|
||||
let buffer = ""
|
||||
function send(message) {
|
||||
process.stdout.write(JSON.stringify(message) + "\\n")
|
||||
}
|
||||
function result(request, result) {
|
||||
send({ jsonrpc: "2.0", id: request.id, result })
|
||||
}
|
||||
process.stdin.setEncoding("utf8")
|
||||
process.stdin.on("data", (chunk) => {
|
||||
buffer += chunk
|
||||
while (true) {
|
||||
const index = buffer.indexOf("\\n")
|
||||
if (index === -1) return
|
||||
const line = buffer.slice(0, index).trim()
|
||||
buffer = buffer.slice(index + 1)
|
||||
if (!line) continue
|
||||
const request = JSON.parse(line)
|
||||
if (request.method === "initialize") {
|
||||
result(request, { protocolVersion: request.params?.protocolVersion ?? "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: id, version: "1.0.0" } })
|
||||
continue
|
||||
}
|
||||
if (request.method === "tools/list") {
|
||||
result(request, { tools: [{ name: "ping", description: "Return a deterministic response", inputSchema: { type: "object", properties: {}, additionalProperties: false } }] })
|
||||
continue
|
||||
}
|
||||
if (request.method === "tools/call") {
|
||||
result(request, { content: [{ type: "text", text: id + " pong" }] })
|
||||
continue
|
||||
}
|
||||
if (request.id !== undefined) result(request, {})
|
||||
}
|
||||
})
|
||||
setInterval(() => {}, 1000)
|
||||
`,
|
||||
)
|
||||
return pathToFileURL(stubDir).href
|
||||
}
|
||||
|
||||
mkdirSync(project, { recursive: true })
|
||||
|
||||
function appendOutput(text) {
|
||||
output += text
|
||||
writeFileSync(outputFile, output)
|
||||
}
|
||||
|
||||
async function readBody(req) {
|
||||
const chunks = []
|
||||
for await (const chunk of req) chunks.push(chunk)
|
||||
return Buffer.concat(chunks).toString("utf8")
|
||||
}
|
||||
|
||||
function writeSse(res, event) {
|
||||
res.write(`data: ${JSON.stringify(event)}\n\n`)
|
||||
}
|
||||
|
||||
function responseText() {
|
||||
if (scenario.startsWith("bash-")) {
|
||||
return "The bash tool completed and the session continued after tool execution."
|
||||
}
|
||||
|
||||
if (scenario.startsWith("task-")) {
|
||||
return "The subagent task completed and returned control to the parent session."
|
||||
}
|
||||
|
||||
if (scenario.startsWith("mcp-npx")) {
|
||||
return "The MCP npx spawn storm initialized and the session completed."
|
||||
}
|
||||
|
||||
if (scenario.startsWith("markdown")) {
|
||||
const sections = []
|
||||
for (let i = 0; i < 40; i++) {
|
||||
sections.push(`## Section ${i + 1}`)
|
||||
sections.push("")
|
||||
sections.push("- This line intentionally exercises markdown wrapping in the OpenTUI renderer.")
|
||||
sections.push("- It includes `inline code`, **bold text**, [a link](https://example.com), and CJK text 漢字かなカナ.")
|
||||
sections.push("")
|
||||
sections.push("```ts")
|
||||
sections.push(`const value${i} = { index: ${i}, text: "OpenTUI Windows repro" }`)
|
||||
sections.push("console.log(value" + i + ")")
|
||||
sections.push("```")
|
||||
sections.push("")
|
||||
}
|
||||
return sections.join("\n")
|
||||
}
|
||||
|
||||
return "This is a deterministic CI response from the fake provider."
|
||||
}
|
||||
|
||||
function hasToolResult(parsedBody) {
|
||||
return parsedBody?.messages?.some?.((message) => message.role === "tool" || message.role === "function") ?? false
|
||||
}
|
||||
|
||||
function shouldCallBash(parsedBody) {
|
||||
if (!scenario.startsWith("bash-")) return false
|
||||
if (!parsedBody?.tools?.some?.((tool) => (tool.function?.name ?? tool.name) === "bash")) return false
|
||||
if (hasToolResult(parsedBody) || toolCallSent) return false
|
||||
toolCallSent = true
|
||||
return true
|
||||
}
|
||||
|
||||
function shouldCallTask(parsedBody) {
|
||||
if (!scenario.startsWith("task-")) return false
|
||||
if (!parsedBody?.tools?.some?.((tool) => (tool.function?.name ?? tool.name) === "task")) return false
|
||||
if (hasToolResult(parsedBody) || toolCallSent) return false
|
||||
toolCallSent = true
|
||||
return true
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const body = await readBody(req)
|
||||
providerRequests++
|
||||
const parsedBody = body ? JSON.parse(body) : undefined
|
||||
console.log(
|
||||
`provider request ${providerRequests}: ${req.method} ${req.url} stream=${parsedBody?.stream ?? false} messages=${parsedBody?.messages?.length ?? "n/a"} tools=${parsedBody?.tools?.map?.((tool) => tool.function?.name ?? tool.name).join(",") ?? "none"}`,
|
||||
)
|
||||
writeFileSync(
|
||||
requestsFile,
|
||||
JSON.stringify({ method: req.method, url: req.url, body: parsedBody }) + "\n",
|
||||
{ flag: "a" },
|
||||
)
|
||||
|
||||
if (req.url?.endsWith("/models")) {
|
||||
res.writeHead(200, { "content-type": "application/json" })
|
||||
res.end(JSON.stringify({ object: "list", data: [{ id: "test-model", object: "model" }] }))
|
||||
return
|
||||
}
|
||||
|
||||
if (req.url?.endsWith("/chat/completions")) {
|
||||
const parsed = parsedBody ?? {}
|
||||
const callBash = shouldCallBash(parsed)
|
||||
const callTask = shouldCallTask(parsed)
|
||||
const text = responseText()
|
||||
if (parsed.stream) {
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-cache",
|
||||
connection: "keep-alive",
|
||||
})
|
||||
writeSse(res, {
|
||||
id: "chatcmpl-opentui-repro",
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: "test-model",
|
||||
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
||||
})
|
||||
await delay(50)
|
||||
if (callBash || callTask) {
|
||||
const toolName = callBash ? "bash" : "task"
|
||||
const toolArguments = callBash
|
||||
? { command: "echo opentui-tool-repro > opentui-tool-repro.txt" }
|
||||
: {
|
||||
description: "subagent repro",
|
||||
prompt: "Respond with one sentence for the Windows OpenTUI crash repro.",
|
||||
subagent_type: "general",
|
||||
}
|
||||
writeSse(res, {
|
||||
id: "chatcmpl-opentui-repro",
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: "test-model",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: `call_opentui_repro_${toolName}`,
|
||||
type: "function",
|
||||
function: {
|
||||
name: toolName,
|
||||
arguments: JSON.stringify(toolArguments),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
await delay(50)
|
||||
writeSse(res, {
|
||||
id: "chatcmpl-opentui-repro",
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: "test-model",
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
|
||||
})
|
||||
res.write("data: [DONE]\n\n")
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
writeSse(res, {
|
||||
id: "chatcmpl-opentui-repro",
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: "test-model",
|
||||
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
|
||||
})
|
||||
await delay(50)
|
||||
writeSse(res, {
|
||||
id: "chatcmpl-opentui-repro",
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: "test-model",
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
})
|
||||
res.write("data: [DONE]\n\n")
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(200, { "content-type": "application/json" })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: "chatcmpl-opentui-repro",
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: "test-model",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: text },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(404, { "content-type": "application/json" })
|
||||
res.end(JSON.stringify({ error: { message: `Unhandled fake provider route: ${req.url}` } }))
|
||||
})
|
||||
|
||||
await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen))
|
||||
const { port } = server.address()
|
||||
const baseURL = `http://127.0.0.1:${port}/v1`
|
||||
const mcpPackageUrl = scenario.startsWith("mcp-npx") ? writeMcpStubPackage() : undefined
|
||||
const mcpServers = {}
|
||||
if (mcpPackageUrl) {
|
||||
for (let index = 1; index <= 12; index++) {
|
||||
const id = `server${String(index).padStart(2, "0")}`
|
||||
mcpServers[id] = {
|
||||
type: "local",
|
||||
command: ["npx", "-y", mcpPackageUrl, "--id", id],
|
||||
timeout: 15000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
join(project, "opencode.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
autoupdate: false,
|
||||
...(mcpPackageUrl ? { mcp: mcpServers } : {}),
|
||||
enabled_providers: ["test"],
|
||||
provider: {
|
||||
test: {
|
||||
name: "Test",
|
||||
id: "test",
|
||||
env: [],
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
models: {
|
||||
"test-model": {
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
temperature: false,
|
||||
tool_call: true,
|
||||
release_date: "2025-01-01",
|
||||
limit: { context: 100000, output: 10000 },
|
||||
cost: { input: 0, output: 0 },
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
options: { apiKey: "test-key", baseURL },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
)
|
||||
|
||||
console.log(`scenario=${scenario}`)
|
||||
if (mcpPackageUrl) console.log(`mcp package=${mcpPackageUrl}`)
|
||||
console.log(`fake provider baseURL=${baseURL}`)
|
||||
console.log(`pty output log=${outputFile}`)
|
||||
console.log(`provider requests log=${requestsFile}`)
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
||||
OPENCODE_DISABLE_EXTERNAL_SKILLS: "1",
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "1",
|
||||
OPENCODE_LOG_LEVEL: "DEBUG",
|
||||
OPENCODE_PRINT_LOGS: "1",
|
||||
OPENCODE_PURE: "1",
|
||||
OTUI_DEBUG: "1",
|
||||
OTUI_DUMP_CAPTURES: "1",
|
||||
...(scenario.includes("no-native-render") ? { OTUI_NO_NATIVE_RENDER: "1" } : {}),
|
||||
OTUI_USE_CONSOLE: "1",
|
||||
OTUI_SHOW_STATS: "1",
|
||||
}
|
||||
|
||||
const proc = pty.spawn(
|
||||
exe,
|
||||
[
|
||||
"--model",
|
||||
"test/test-model",
|
||||
"--prompt",
|
||||
scenario.startsWith("bash-")
|
||||
? "run a shell command through the bash tool, then summarize"
|
||||
: scenario.startsWith("task-")
|
||||
? "delegate a small task to a subagent, then summarize"
|
||||
: "start a CI repro session and answer briefly",
|
||||
],
|
||||
{
|
||||
name: "xterm-256color",
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: resolve(project),
|
||||
env,
|
||||
},
|
||||
)
|
||||
|
||||
proc.onData((data) => {
|
||||
appendOutput(data)
|
||||
process.stdout.write(data)
|
||||
if (
|
||||
(scenario === "bash-permission" || scenario === "task-permission") &&
|
||||
!permissionSubmitted &&
|
||||
/Permission required|Allow once|Call tool bash|Call tool task|subagent repro/.test(output)
|
||||
) {
|
||||
permissionSubmitted = true
|
||||
setTimeout(() => proc.write("\r"), 250)
|
||||
}
|
||||
})
|
||||
|
||||
proc.onExit((event) => {
|
||||
exited = true
|
||||
exitCode = event.exitCode
|
||||
exitSignal = event.signal
|
||||
})
|
||||
|
||||
const deadline = Date.now() + seconds * 1000
|
||||
let completedAt
|
||||
while (Date.now() < deadline) {
|
||||
if (crashPattern.test(output)) break
|
||||
if (exited) break
|
||||
if (!completedAt && output.includes("message=\"exiting loop\"")) completedAt = Date.now()
|
||||
if (completedAt && Date.now() - completedAt > 8000) break
|
||||
await delay(500)
|
||||
}
|
||||
|
||||
if (!exited) {
|
||||
proc.write("\x03")
|
||||
await delay(1500)
|
||||
}
|
||||
if (!exited) {
|
||||
proc.kill()
|
||||
await delay(500)
|
||||
}
|
||||
|
||||
await new Promise((resolveClose) => server.close(resolveClose))
|
||||
|
||||
console.log(`\npty exitCode=${exitCode} signal=${exitSignal} providerRequests=${providerRequests}`)
|
||||
|
||||
if (crashPattern.test(output)) {
|
||||
throw new Error("native crash signature detected in PTY session")
|
||||
}
|
||||
|
||||
if (exitCode === 3 || exitCode === -1073741819 || exitCode === -1073740791 || exitCode === -1073741571) {
|
||||
throw new Error(`native crash exit code detected in PTY session: ${exitCode}`)
|
||||
}
|
||||
|
||||
if (providerRequests === 0) {
|
||||
throw new Error("PTY session did not send a request to the fake provider")
|
||||
}
|
||||
|
||||
process.exit(0)
|
||||
Reference in New Issue
Block a user