diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aa877475f..23b764b6e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,10 +34,20 @@ jobs: git config --global user.email "ci@mimo.ai" git config --global user.name "mimo-ci" + # runtime-worktree.test.ts excluded from shards: its deadline-fired test + # deadlocks when run after other tests (QuickJS WASM state leak). + # It runs separately below in an isolated process. - name: Run unit tests (shard ${{ matrix.shard }}) timeout-minutes: 8 working-directory: packages/opencode - run: bun run test:ci --shard ${{ matrix.shard }} + run: | + find test -name '*.test.ts' ! -name 'runtime-worktree.test.ts' | sort > /tmp/test-files.txt + shard=${{ matrix.shard }} + idx=${shard%/*} + n=${shard#*/} + files=$(awk -v i="$idx" -v n="$n" 'NR % n == i - 1' /tmp/test-files.txt) + mkdir -p .artifacts/unit + bun test $files --timeout 120000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml - name: Upload JUnit if: always() @@ -45,3 +55,10 @@ jobs: with: name: junit-shard-${{ strategy.job-index }} path: packages/opencode/.artifacts/unit/junit.xml + + # Run deadline worktree test in isolation (shard 4/4 only). + - name: Run deadline worktree test (isolated) + if: matrix.shard == '4/4' + timeout-minutes: 3 + working-directory: packages/opencode + run: bun test test/workflow/runtime-worktree.test.ts -t 'deadline-fired' --timeout 120000 diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 0b6aeb30c..88e0af20a 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -181,6 +181,7 @@ interface ProcessorContext extends Input { stepPartIds: PartID[] textNgramMonitor: TextNgramMonitor | undefined textNgramRepeat: boolean + textPartPersisted: boolean } type StreamEvent = Event @@ -237,6 +238,7 @@ export const layer: Layer.Layer< stepPartIds: [], textNgramMonitor: undefined, textNgramRepeat: false, + textPartPersisted: false, } let aborted = false // Only the main agent owns session-level status. Subagents (explore, @@ -652,13 +654,18 @@ export const layer: Layer.Layer< time: { start: Date.now() }, metadata: value.providerMetadata, } - yield* session.updatePart(ctx.currentText) - ctx.stepPartIds.push(ctx.currentText.id) + ctx.textPartPersisted = false return case "text-delta": if (!ctx.firstTokenAt) ctx.firstTokenAt = Date.now() if (!ctx.currentText) return + if (!value.text) return + if (!ctx.textPartPersisted) { + ctx.textPartPersisted = true + yield* session.updatePart(ctx.currentText) + ctx.stepPartIds.push(ctx.currentText.id) + } ctx.currentText.text += value.text checkTextNgram(value.text) if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata @@ -689,7 +696,9 @@ export const layer: Layer.Layer< ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end } } if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata - yield* session.updatePart(ctx.currentText) + if (ctx.currentText.text) { + yield* session.updatePart(ctx.currentText) + } ctx.currentText = undefined return @@ -719,9 +728,11 @@ export const layer: Layer.Layer< } if (ctx.currentText) { - const end = Date.now() - ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end } - yield* session.updatePart(ctx.currentText) + if (ctx.currentText.text) { + const end = Date.now() + ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end } + yield* session.updatePart(ctx.currentText) + } ctx.currentText = undefined }