Skip to content

Empty bash tool arguments {} can still be submitted and interrupt the session #27871

Closed as not planned

Description

@eatFirewood

Description

A bash tool call with completely empty arguments ({}) was submitted from an OpenCode session and caused the tool execution to be interrupted. This looks related to the previously reported empty-input / aborted-tool family of bugs, but it is still observable on OpenCode 1.15.2 and with openai/gpt-5.5.

The problematic tool call shape was:

{
  "recipient_name": "functions.bash",
  "parameters": {}
}

For the bash tool this is invalid because required fields such as command, workdir, and description are absent. The runtime/tool layer should reject this before execution with a clear validation error and return control to the user, rather than allowing the session to enter an interrupted/stuck state.

What happened

During a normal assistant session, the assistant attempted to run a parallel tool batch that included:

  1. A valid read call for a JSON config file.
  2. An invalid bash call whose parameters were {}.

The UI showed the read result, then the invalid bash tool reported:

[Tool execution was interrupted]

From the user perspective, this made the current OpenCode session difficult to exit/recover from. The user explicitly reported that this kind of invalid empty-argument tool call has caused them to be unable to exit OpenCode cleanly in prior sessions as well.

Concrete transcript evidence

The assistant later acknowledged the exact invalid tool call as:

{"recipient_name":"functions.bash","parameters":{}}

The current session search also finds the same call shape in the session history:

session: ses_1cf21f371ffe4YhyJkDKmeU31Q
match: {"recipient_name":"functions.bash","parameters":{}}

The tool call was produced inside a multi_tool_use.parallel wrapper. The valid sibling call completed, while the invalid bash call had no arguments at all.

Expected behavior

OpenCode should guard against empty arguments for tools with required parameters.

For bash, if input is {} or missing required fields, OpenCode should:

  1. Not start tool execution.

  2. Fail the tool call immediately with a schema/validation error such as:

    bash tool call rejected: missing required field `command`
    
  3. Return control to the user cleanly.

  4. Avoid leaving the session in an interrupted, half-running, or unrecoverable state.

  5. Ideally provide model-facing feedback so the assistant can retry with a valid tool call or stop.

Actual behavior

The invalid bash tool call was submitted with empty arguments:

"parameters": {}

The session then showed:

[Tool execution was interrupted]

The user experienced the session as stuck / hard to exit.

Why this matters

This is especially harmful because {} is not a subtly malformed bash call. It is obviously invalid for the tool schema. Users should not have to recover manually from a model/tool-stream failure that could be rejected before execution.

This also makes the behavior look intentional or malicious to users, because the invalid call is visibly empty and the resulting session becomes hard to control.

Environment

  • OpenCode version: 1.15.2
  • Model: openai/gpt-5.5
  • OS/session: Linux, KDE Plasma, Wayland
  • Shell: bash
  • Harness/plugins: OhMyOpenCode / Sisyphus-style tool orchestration was active
  • Tool path involved: multi_tool_use.parallel containing a functions.bash child call

Related issues / PRs

This appears related to the known empty-tool-input and aborted-tool-call issue family:

Suggested fix direction

Add a generic pre-execution guard for all tool calls:

  1. If a tool has required parameters and the input is null, undefined, or {}, reject before execution.

  2. Surface a structured validation error to both the UI and the model.

  3. For batched/parallel tool calls, fail only the invalid child call and keep the parent session recoverable.

  4. Add regression coverage specifically for:

    { "tool": "bash", "input": {} }
  5. Verify the same guard applies when the empty input originates from multi_tool_use.parallel / batched tool execution, not only direct tool calls.

Minimal reproduction shape

I do not have a deterministic model-only repro, but the runtime should be able to unit-test this directly by simulating a tool call equivalent to:

{
  "tool": "bash",
  "input": {}
}

or, through the parallel wrapper:

{
  "tool": "multi_tool_use.parallel",
  "input": {
    "tool_uses": [
      {
        "recipient_name": "functions.read",
        "parameters": {
          "filePath": "/some/file.json",
          "offset": 0,
          "limit": 200
        }
      },
      {
        "recipient_name": "functions.bash",
        "parameters": {}
      }
    ]
  }
}

The expected outcome is a clean validation failure for the second child call, not a tool interruption or session-level recovery problem.

Activity

  1. github-actions commented on May 16, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    If you believe this is a distinct new case, please clarify how it differs from the issues above.

  2. eatFirewood commented on May 16, 2026

    @eatFirewood
    Author

    Additional forensic detail / correction after inspecting the local OpenCode DB and logs:

    I want to refine the original wording. The strongest evidence does not show that the model completed a bash tool call with literal {} arguments. It looks more like OpenCode created/persisted a bash tool-call part and then the stream/runtime was interrupted before any argument payload was captured.

    Local DB evidence

    The exact part row for the problematic bash tool call is:

    {
      "type": "tool",
      "tool": "bash",
      "callID": "call_yeC2JnBpLjawqbv2JpFy3wNB",
      "state": {
        "status": "pending",
        "input": {},
        "raw": ""
      }
    }

    Important details:

    • status is pending, not completed or error.
    • input is {}.
    • raw is an empty string "", not the string "{}".

    That distinction matters. If the model had fully emitted empty JSON args, I would expect something closer to raw: "{}" followed by schema validation failure. Instead, this looks like a tool-call shell was created, but argument streaming never completed.

    The same assistant message has no normal finish/token accounting:

    {
      "id": "msg_e30e99482001wX4eo84q5SKVK2",
      "modelID": "gpt-5.5",
      "providerID": "openai",
      "tokens": {
        "input": 0,
        "output": 0,
        "reasoning": 0,
        "cache": {
          "read": 0,
          "write": 0
        }
      }
    }

    It also lacks a normal finish value.

    Neighboring parts in the same assistant message

    The message parts show this sequence:

    1. step-start

    2. empty reasoning part

    3. user-visible commentary text:

      我会先让 Clash Verge 主界面进程退出,避免它把旧状态写回去,然后备份并修改 `window_state.json`。
      
    4. a completed read tool call with valid input/output:

      {
        "type": "tool",
        "tool": "read",
        "state": {
          "status": "completed",
          "input": {
            "filePath": "/home/wt/.config/io.github.clash-verge-rev.clash-verge-rev/window_state.json",
            "offset": 0,
            "limit": 200
          }
        }
      }
    5. the pending bash tool call with empty input and empty raw shown above.

    Runtime log evidence

    Around the same time, after the read tool completed, the OpenCode log shows:

    INFO  ... permission=read pattern=home/wt/.config/io.github.clash-verge-rev.clash-verge-rev/window_state.json ... evaluated
    INFO  ... service=lsp file=/home/wt/.config/io.github.clash-verge-rev.clash-verge-rev/window_state.json touching file
    ERROR ... service=default e=InstanceRef not provided rejection
    WARN  ... service=default error=Worker has been terminated worker shutdown failed
    

    This suggests a runtime/worker-side failure occurred while the assistant message/tool batch was in progress.

    Updated hypothesis

    The likely sequence is:

    1. The assistant began a response.

    2. It emitted commentary saying it would stop the Clash Verge UI process and edit window_state.json.

    3. It successfully executed a read call for window_state.json.

    4. A bash tool-call part was created/persisted.

    5. Before any bash arguments were captured, the OpenCode runtime/worker hit InstanceRef not provided rejection and then worker shutdown failed.

    6. The session was left with a pending bash tool part:

      { "tool": "bash", "state": { "status": "pending", "input": {}, "raw": "" } }

    So this may be less "model deliberately emitted {}" and more "runtime interrupted during tool-call assembly, leaving a half-created tool call with default empty input".

    Why this still belongs in the empty-input/tool-interruption family

    Even if the model did not intentionally emit {}, the stored session state still contains a bash tool part with input: {} and no raw args. This is the exact state that later becomes confusing/unrecoverable for the user and can be rendered as an empty-argument tool call.

    Suggested additional fix direction

    Besides validating {} before tool execution, OpenCode should probably also guard/clean up this intermediate state:

    • If a tool part is still pending with raw: "" when the assistant stream/worker fails, mark it as an interrupted error with a synthetic tool result or remove it from model-visible history.

    • Preserve the underlying runtime error (InstanceRef not provided) in the part metadata/error so the UI shows the true cause instead of making it look like the model intentionally called bash with {}.

    • Add regression coverage for a partially-created bash part:

      {
        "type": "tool",
        "tool": "bash",
        "state": {
          "status": "pending",
          "input": {},
          "raw": ""
        }
      }
    • Ensure worker/runtime errors during tool-call streaming always leave session history in a recoverable state.

  3. eatFirewood commented on May 16, 2026

    @eatFirewood
    Author

    Additional clarification: this appears to be two separate bugs that combine into the user-visible failure.

    1. Triggering bug: the stream/worker can fail while a tool input is only partially assembled

    The local evidence suggests the bash part was created after tool-input-start, but the stream failed before the later tool-call event that would carry the completed arguments.

    That leaves this intermediate state:

    {
      "type": "tool",
      "tool": "bash",
      "state": {
        "status": "pending",
        "input": {},
        "raw": ""
      }
    }

    In this state, input: {} is not necessarily a completed model-emitted empty argument object. It is the processor's initial placeholder for a tool input that has started streaming but has not reached the executable tool-call phase.

    The surrounding runtime log showed:

    ERROR ... service=default e=InstanceRef not provided rejection
    WARN  ... service=default error=Worker has been terminated worker shutdown failed
    

    So the likely trigger is an upstream worker/runtime/context failure while the tool input stream was in progress. Subagents/background jobs can amplify this class of failure because a task tool is itself an agent loop with additional parent/child session lifecycle and background job state, but the first observed pending bash input:{} raw:"" incident happened in the parent session before the later nested plan/explore/librarian subagents were launched.

    2. Recovery bug: SessionProcessor can treat an incomplete pending tool as something worth waiting for

    The second bug is independent and is the safer one to fix directly in the session processor.

    The relevant state machine is:

    tool-input-start -> create pending tool part with input:{} raw:""
    tool-call        -> completed arguments received; mark tool running and store input
    tool-result/error -> settle the running tool call
    

    A pending tool is not executable. It has no completed arguments and no tool executor has started. Only a running tool can later produce a tool result/error.

    In the implementation, the deferred for a tool call is created at tool-input-start, but it is only settled by completeToolCall / failToolCall; both paths require the stored part to be running. Therefore, if the stream fails after tool-input-start but before tool-call, awaiting that deferred is a guaranteed dead wait: no future code path can settle it.

    This is why a timeout is not the right fix. Tool runtime duration is inherently unbounded; a slow bash/test/build command may be legitimate. The precise condition is not elapsed time. The precise condition is state:

    • running: tool has complete input and started execution, so cleanup may wait for the result.
    • pending: tool input was never completed, so cleanup must not wait for an execution result that cannot exist.

    Suggested precise fix

    In SessionProcessor.cleanup, only wait for unresolved tool calls whose persisted part is still running. Do not wait for pending tool parts. Let the existing cleanup path convert remaining tool parts into an interrupted error so model-visible history remains recoverable.

    Pseudo-shape:

    if (!aborted && !assistantMessage.error) {
      for (const toolCallID of Object.keys(ctx.toolcalls)) {
        const match = yield* readToolCall(toolCallID)
        if (match?.part.state.status !== "running") continue
        yield* Deferred.await(match.call.done)
      }
    }
    
    // Then convert any remaining pending/running tool parts to interrupted errors.

    Regression test shape

    A focused regression test can simulate:

    1. LLM stream emits tool-input-start for bash.

    2. LLM/worker stream fails before emitting tool-call.

    3. SessionProcessor.process() returns/stops instead of hanging.

    4. The stored tool part is converted from:

      { "status": "pending", "input": {}, "raw": "" }

      to an interrupted error with metadata.interrupted === true.

    This addresses the session corruption/hang even if the upstream InstanceRef / worker-context failure is fixed separately later.

  4. eatFirewood commented on May 16, 2026

    @eatFirewood
    Author

    Additional finding: there is another reproducible-looking trigger in the same stuck-session family. This one does not require an empty bash {} call. It happens after a normal read tool call completes and the read tool starts its LSP warm-up side effect.

    I am adding it here because it explains why the original symptom can be frequent: a common read operation can trip a runtime/context defect, and then the session processor may leave the assistant message in an unfinished state.

    Sanitized triggering sequence

    During a normal code-analysis turn, the assistant started responding and read an OpenCode TypeScript source file:

    {
      "type": "tool",
      "tool": "read",
      "state": {
        "status": "completed",
        "input": {
          "filePath": "packages/opencode/src/session/processor.ts",
          "offset": 620,
          "limit": 110
        }
      }
    }

    Immediately after that successful read, the log showed:

    service=lsp file=packages/opencode/src/session/processor.ts touching file
    service=lsp.server typescript server
    service=lsp serverID=biome root=... spawned lsp server
    service=lsp.client serverID=biome initialized
    ERROR ... service=default e=InstanceRef not provided rejection
    

    The corresponding assistant message was left with zero token accounting and no normal finish/error:

    {
      "role": "assistant",
      "modelID": "gpt-5.5",
      "providerID": "openai",
      "tokens": {
        "input": 0,
        "output": 0,
        "reasoning": 0,
        "cache": { "read": 0, "write": 0 }
      }
    }

    So this failure mode is not "the model emitted an empty bash call". The completed tool in this case was a valid read. The session still became stuck because a background/runtime side effect failed after the tool completed.

    Why LSP is involved

    read is not only a file-read operation. After producing the file output, it also warms LSP:

    const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) {
      yield* lsp.touchFile(filepath).pipe(Effect.ignore, Effect.forkIn(scope))
    })
    
    // later:
    yield* warm(filepath)

    lsp.touchFile() needs an instance-scoped context:

    const getClients = Effect.fnUntraced(function* (file: string) {
      const ctx = yield* InstanceState.context
      ...
    })

    and InstanceState.context currently defects if InstanceRef is absent:

    export const context = Effect.gen(function* () {
      const ctx = yield* InstanceRef
      if (!ctx) return yield* Effect.die(new Error("InstanceRef not provided"))
      return ctx
    })

    This means a best-effort background optimization attached to read can surface as a runtime rejection instead of being contained as a normal tool error or skipped LSP warm-up.

    Why this matters for the original issue

    The original report focused on a persisted pending bash part with:

    { "status": "pending", "input": {}, "raw": "" }

    That is still a real recovery/state-machine bug. But the newer evidence shows a broader pattern:

    1. A normal tool operation succeeds.
    2. A runtime/background side effect fails outside the normal tool result path.
    3. The assistant stream/message is left without a normal finish, error, or completed cleanup state.
    4. The UI/session then appears stuck or unrecoverable.

    This also explains the frequency: reading source/config/log files is common, and read can trigger LSP warm-up even though the user-visible tool call itself is valid.

    Suggested fix direction

    I think this should be fixed at two boundaries.

    First, make read's LSP warm-up truly best-effort:

    • read should return successfully even if LSP warm-up cannot run.
    • Missing InstanceRef during a non-essential warm-up should be skipped or logged, not raised as a runtime defect.
    • If LSP genuinely requires InstanceRef, capture/provide it explicitly before forking the warm-up effect, or make this specific warm-up path no-op when no instance context is available.
    • Catch the full cause of the forked warm-up, including defects, so it cannot escape as an unhandled runtime rejection.

    Second, keep the session processor resilient to runtime defects:

    • If the LLM stream/tool side effects fail after a tool completed, the assistant message should still be finalized into a recoverable state.
    • Cleanup should not leave messages with zero tokens, no finish, no error, and no completion timestamp.
    • Pending tool input (pending + input:{} + raw:"") and stale running tools should be converted into explicit interrupted/error states rather than left as waitable work.

    Regression coverage

    Useful regression tests would be:

    • A read tool call where lsp.touchFile() defects with InstanceRef not provided; expected result: the read output is still returned and no unhandled rejection escapes.
    • A processor-level test where a completed read is followed by a runtime-side LSP defect; expected result: the session exits cleanly and the assistant message is not left half-created.
    • Existing pending-tool cleanup coverage for the earlier case where tool-input-start fires but the stream fails before tool-call.

    If maintainers prefer, this LSP/context issue can be split into a separate issue. I am adding it here because it appears to be another trigger for the same user-visible stuck-session behavior.

  5. InCerryGit commented on Jul 8, 2026

    @InCerryGit

    Additional evidence from OpenCode 1.17.15 showing the same stuck-session family is still observable on the current release.

    This is intentionally sanitized: project names, local paths, repository names, and real session/message/call IDs are replaced with placeholders.

    Environment

    • OpenCode version: 1.17.15
    • Model: openai/gpt-5.5
    • Agent mode: deep/subagent-style agent loop
    • OS: Linux / WSL-style environment
    • LazyCodex / OMO plugin surface was present, but the observed state maps to OpenCode core session/tool-call state.

    Observed symptom

    A long-running session stopped making provider/API requests and appeared stuck. Inspecting the local OpenCode SQLite state showed that the session was not blocked on a real shell/test process. There was no matching long-running dotnet test, shell command, or project command still alive on the machine.

    The last assistant message had completed a normal step with finish: "tool-calls", then a following assistant message was created with zero token accounting and no useful progress. The final persisted part was a bash tool placeholder that never advanced beyond pending:

    {
      "type": "tool",
      "tool": "bash",
      "callID": "call_REDACTED",
      "state": {
        "status": "pending",
        "input": {},
        "raw": ""
      }
    }

    The adjacent event sequence was effectively:

    assistant message A: finish = "tool-calls"
    tool part in message A: todowrite completed
    tool part in message A: todowrite aborted/interrupted
    assistant message B: created with zero token accounting
    part in message B: step-start
    part in message B: bash pending, input {}, raw ""
    

    The important distinction is the same one noted above in this issue: this does not look like a completed model-emitted {} argument object. It looks like OpenCode persisted the initial tool-input-start placeholder and never reached the later event that would carry complete executable arguments.

    Why this looks like the same root family

    The stored bash part has exactly the intermediate shape discussed earlier:

    • status: "pending"
    • input: {}
    • raw: ""

    Since the part is still pending, there is no command to execute and no running shell process that can later produce a result. From the user perspective the session looks like it is still working, but provider/API traffic has stopped because the local session/tool-call lifecycle has no recoverable next step.

    Source-level correspondence in current dev

    Current packages/opencode/src/session/processor.ts still initializes a new tool part in ensureToolCall() with:

    state: { status: "pending", input: {}, raw: "" }

    and only moves it to running when a later tool-call event provides completed input.

    The current cleanup code does attempt to convert remaining tool calls to Tool execution aborted, but this real session on 1.17.15 still ended with a persisted pending placeholder. That suggests either cleanup did not run, did not reach this part, or the follow-up assistant message created a new pending placeholder after the interrupted cleanup path.

    Suggested regression coverage

    Please consider a processor-level regression that simulates:

    1. A normal assistant step that finishes with tool-calls.
    2. A subsequent streamed tool input for bash that reaches tool-input-start / placeholder creation.
    3. An interruption before the completed tool-call event arrives.
    4. The loop exits or recovers without leaving any persisted tool part in pending + input:{} + raw:"" state.

    Expected result: the placeholder should be converted into an explicit interrupted/error state, or otherwise removed from model-visible recoverable history. It should not remain as waitable work.

  6. InCerryGit commented on Jul 9, 2026

    @InCerryGit

    Additional sanitized evidence from another occurrence in the same stuck-session family.

    This one is slightly different from the earlier bash case: the affected tool was skill, and the session eventually self-recovered after a long delay instead of remaining permanently stuck.

    Environment

    • OpenCode version recorded on the affected session: 1.17.8
    • Model: openai/gpt-5.5
    • Session type: long-running session loop
    • OS: Linux / WSL-style environment

    Observed timeline

    The user requested a read-only comparison task. The session then entered this sequence:

    T+00:00  user message accepted
    T+00:02  assistant message created
    T+00:08  step-start
    T+00:14  tool=skill persisted as pending, input={}, raw=""
    T+~09:00 pending skill tool is marked error: "Tool execution aborted" / metadata.interrupted=true
    T+~09:00 assistant retries/continues and invokes the same skill successfully
    T+~09:20 subsequent bash/codegraph/read tools complete normally
    T+~10:50 final assistant message finishes with finish="stop"
    

    The initially stuck part had this shape:

    {
      "type": "tool",
      "tool": "skill",
      "callID": "call_REDACTED",
      "state": {
        "status": "pending",
        "input": {},
        "raw": ""
      }
    }

    After the delay, the same part was converted to an interrupted error:

    {
      "type": "tool",
      "tool": "skill",
      "callID": "call_REDACTED",
      "state": {
        "status": "error",
        "input": {},
        "raw": "",
        "error": "Tool execution aborted",
        "metadata": {
          "interrupted": true
        }
      }
    }

    The next continuation then successfully loaded the same skill with real input:

    {
      "type": "tool",
      "tool": "skill",
      "callID": "call_REDACTED",
      "state": {
        "status": "completed",
        "input": {
          "name": "REDACTED_SKILL_NAME",
          "user_message": "REDACTED_TASK_DESCRIPTION"
        }
      }
    }

    Why this looks related

    This reinforces the distinction already discussed above: input: {} plus raw: "" looks like an initial tool-input-start placeholder, not a completed model-emitted empty JSON argument object.

    The new part of this report is the long recovery window:

    • The session appeared stuck from the user's point of view because no useful tool execution happened for several minutes.
    • There was no corresponding long-running project command still alive on the machine.
    • The runtime eventually converted the placeholder to Tool execution aborted, retried/continued, and completed the task.

    So the user-visible failure mode is not only "permanently stuck". It can also be "stuck for a long time, then self-recovers after an interrupted pending tool input is aborted".

    Suggested regression coverage

    Please consider covering a non-bash tool as well, e.g. skill or another tool-loader path:

    1. A tool input starts and creates a pending placeholder:

      { "tool": "skill", "state": { "status": "pending", "input": {}, "raw": "" } }
    2. The stream/runtime does not deliver the completed tool-call event for several minutes or is interrupted before it arrives.

    3. The processor should not leave the UI/session looking active indefinitely.

    4. The pending placeholder should transition quickly to an explicit interrupted/error state, or the user should get a clear retry/error status.

    5. A later continuation should not require waiting for a long timeout/backoff window before progress resumes.

    This looks closely related to #33687 and #35454 as well: interrupted tool-input state and UI/session convergence after a tool is durably aborted.

  7. github-actions commented on Sep 8, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions