Skip to content

Commit c5da074

Browse files
committed
bugfix: show full stack trace in preprocessor error block
1 parent 976c7f5 commit c5da074

5 files changed

Lines changed: 248 additions & 17 deletions

File tree

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
{
2-
"status": "open"
3-
}
2+
"status" : "closed",
3+
"resolution" : "implemented",
4+
"target_branch" : "v2.1"
5+
}

.cat/issues/v2/v2.1/fix-preprocessor-error-stacktrace/plan.md

Lines changed: 173 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,145 @@
22

33
## Goal
44

5-
Fix preprocessor error to show full stacktrace instead of first line only. When a preprocessor directive fails, the error message currently shows only the exception class and method (e.g., `io.github.cowwoc.cat.hooks.skills.GetOutput.<init>()`). It should show the complete stack trace including all frames and cause chains to aid debugging.
5+
Fix preprocessor error to show full stacktrace instead of first line only. When a preprocessor directive fails,
6+
the error message currently shows only the exception class and method (e.g.,
7+
`io.github.cowwoc.cat.hooks.skills.GetOutput.<init>()`). It should show the complete stack trace including all
8+
frames and cause chains to aid debugging.
9+
10+
## Research Findings
11+
12+
### Root Cause
13+
14+
In `client/src/main/java/io/github/cowwoc/cat/hooks/util/GetSkill.java`, the method `invokeSkillOutput()`
15+
(around lines 650-692) catches exceptions and extracts only `e.getMessage()` or falls back to
16+
`e.getClass().getName()`. For `NoSuchMethodException`, `getMessage()` returns just the constructor signature
17+
(e.g., `io.github.cowwoc.cat.hooks.skills.GetOutput.<init>()`), which is not human-readable.
18+
19+
The `buildPreprocessorErrorMessage()` method (around lines 704-720) formats the `**Preprocessor Error**`
20+
block shown to agents. It currently includes only the short error message, not the full stack trace.
21+
22+
Logback is configured to write to `System.err` only (`client/src/main/resources/logback.xml`). Since the
23+
binary runs as a subprocess, stderr is discarded — logging is useless for this error.
24+
25+
### Fix Approach
26+
27+
1. Capture the full stack trace via `StringWriter`/`e.printStackTrace(pw)` in both catch blocks in
28+
`invokeSkillOutput()`.
29+
2. Update `buildPreprocessorErrorMessage()` to accept the stack trace string and include it as a
30+
`**Stack Trace:**` section in the `**Preprocessor Error**` block.
31+
3. Use `e.toString()` instead of `e.getMessage()` as the primary error line (includes exception type).
32+
4. For `InvocationTargetException`, unwrap to the cause first, then capture its stack trace.
33+
34+
### Key File
35+
36+
`client/src/main/java/io/github/cowwoc/cat/hooks/util/GetSkill.java`
37+
38+
Current catch blocks in `invokeSkillOutput()`:
39+
```java
40+
catch (InvocationTargetException e)
41+
{
42+
Throwable cause = e.getCause();
43+
if (cause == null)
44+
cause = e;
45+
String errorMsg = cause.getMessage();
46+
if (errorMsg == null)
47+
errorMsg = cause.getClass().getName();
48+
return buildPreprocessorErrorMessage(originalDirective, errorMsg);
49+
}
50+
catch (Exception e)
51+
{
52+
String errorMsg = e.getMessage();
53+
if (errorMsg == null)
54+
errorMsg = e.getClass().getName();
55+
return buildPreprocessorErrorMessage(originalDirective, errorMsg);
56+
}
57+
```
58+
59+
Current `buildPreprocessorErrorMessage()`:
60+
```java
61+
private static String buildPreprocessorErrorMessage(String originalDirective, String errorMsg)
62+
{
63+
return """
64+
---
65+
**Preprocessor Error**
66+
67+
A preprocessor directive failed while loading this skill.
68+
69+
**Directive:** `%s`
70+
**Error:** %s
71+
72+
To report this bug, run: `/cat:feedback`
73+
---
74+
""".formatted(originalDirective, errorMsg);
75+
}
76+
```
77+
78+
### Updated Code
79+
80+
Updated catch blocks in `invokeSkillOutput()`:
81+
```java
82+
catch (InvocationTargetException e)
83+
{
84+
Throwable cause = e.getCause();
85+
if (cause == null)
86+
cause = e;
87+
StringWriter sw = new StringWriter();
88+
cause.printStackTrace(new PrintWriter(sw));
89+
return buildPreprocessorErrorMessage(originalDirective, cause.toString(), sw.toString());
90+
}
91+
catch (Exception e)
92+
{
93+
StringWriter sw = new StringWriter();
94+
e.printStackTrace(new PrintWriter(sw));
95+
return buildPreprocessorErrorMessage(originalDirective, e.toString(), sw.toString());
96+
}
97+
```
98+
99+
Updated `buildPreprocessorErrorMessage()`:
100+
```java
101+
private static String buildPreprocessorErrorMessage(String originalDirective, String errorMsg,
102+
String stackTrace)
103+
{
104+
return """
105+
---
106+
**Preprocessor Error**
107+
108+
A preprocessor directive failed while loading this skill.
109+
110+
**Directive:** `%s`
111+
**Error:** %s
112+
113+
**Stack Trace:**
114+
```
115+
%s
116+
```
117+
118+
To report this bug, run: `/cat:feedback`
119+
---
120+
""".formatted(originalDirective, errorMsg, stackTrace.stripTrailing());
121+
}
122+
```
123+
124+
### Required Imports
125+
126+
Ensure these imports exist in `GetSkill.java`:
127+
- `java.io.PrintWriter`
128+
- `java.io.StringWriter`
129+
130+
### Test Location
131+
132+
Add regression test in:
133+
`client/src/test/java/io/github/cowwoc/cat/hooks/util/GetSkillTest.java`
134+
135+
If that file doesn't exist, look for related test files near
136+
`client/src/test/java/io/github/cowwoc/cat/hooks/` and add to the most appropriate one, or create
137+
`GetSkillTest.java` with a license header.
138+
139+
The test should:
140+
1. Simulate a failed directive invocation (or call `buildPreprocessorErrorMessage` directly if it's
141+
accessible, otherwise test via `invokeSkillOutput` at a higher level).
142+
2. Assert the returned string contains a `**Stack Trace:**` section.
143+
3. Assert the stack trace section is non-empty (contains at least one frame).
6144

7145
## Pre-conditions
8146

@@ -13,4 +151,37 @@ Fix preprocessor error to show full stacktrace instead of first line only. When
13151
- [ ] Bug fixed: preprocessor errors display the full stack trace, including all frames and cause chains
14152
- [ ] Regression test added: test verifies full stacktrace is included in the error output
15153
- [ ] No new issues introduced
16-
- [ ] E2E verification: reproduce the preprocessor error scenario and confirm the full stacktrace appears in the output
154+
- [ ] All existing tests pass (`mvn -f client/pom.xml test`)
155+
156+
## Sub-Agent Waves
157+
158+
### Wave 1
159+
160+
- Implement fix in `GetSkill.java` and add regression test
161+
162+
Steps:
163+
1. Read `client/src/main/java/io/github/cowwoc/cat/hooks/util/GetSkill.java` to find exact line
164+
numbers and current code for `invokeSkillOutput()` and `buildPreprocessorErrorMessage()`.
165+
2. Add `java.io.PrintWriter` and `java.io.StringWriter` imports if not already present.
166+
3. Update the `InvocationTargetException` catch block: replace `getMessage()`/`getClass().getName()`
167+
logic with `StringWriter`/`PrintWriter` stack capture using `cause.toString()` as error string.
168+
4. Update the `Exception` catch block: replace `getMessage()`/`getClass().getName()` logic with
169+
`StringWriter`/`PrintWriter` stack capture using `e.toString()` as error string.
170+
5. Update `buildPreprocessorErrorMessage()` signature to accept `String stackTrace` as third
171+
parameter and include `**Stack Trace:**` section in the formatted output.
172+
6. Check whether `client/src/test/java/io/github/cowwoc/cat/hooks/util/GetSkillTest.java` exists.
173+
- If it exists, add the new test to that file.
174+
- If it does not exist, create it with a license header (Java block-comment format per
175+
`.claude/rules/license-header.md`) and a TestNG class skeleton. Do NOT search for other
176+
nearby test files — always use this exact path.
177+
7. Add a test that triggers a preprocessor error and asserts:
178+
- The returned string contains `**Stack Trace:**`
179+
- The stack trace section is non-empty (contains at least one frame line, i.e., `at `)
180+
- If `buildPreprocessorErrorMessage` is `private`, make it package-private (remove `private`,
181+
no access modifier) so the test class in the same package can call it directly. Do NOT use
182+
reflection.
183+
8. Run `mvn -f client/pom.xml test` — all tests must pass.
184+
9. Update `.cat/issues/v2/v2.1/fix-preprocessor-error-stacktrace/index.json` — set
185+
`"status": "closed"`.
186+
10. Commit all changes with message:
187+
`bugfix: show full stack trace in preprocessor error block`

client/src/main/java/io/github/cowwoc/cat/hooks/util/GetSkill.java

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
import io.github.cowwoc.cat.hooks.ShellParser;
1717
import java.io.IOException;
1818
import java.io.PrintStream;
19+
import java.io.PrintWriter;
20+
import java.io.StringWriter;
1921
import java.lang.reflect.InvocationTargetException;
2022
import java.net.URLDecoder;
2123
import java.net.URLEncoder;
@@ -677,31 +679,31 @@ private String invokeSkillOutput(String className, String[] arguments, String or
677679
Throwable cause = e.getCause();
678680
if (cause == null)
679681
cause = e;
680-
String errorMsg = cause.getMessage();
681-
if (errorMsg == null)
682-
errorMsg = cause.getClass().getName();
683-
return buildPreprocessorErrorMessage(originalDirective, errorMsg);
682+
StringWriter stringWriter = new StringWriter();
683+
cause.printStackTrace(new PrintWriter(stringWriter));
684+
return buildPreprocessorErrorMessage(originalDirective, cause.toString(), stringWriter.toString());
684685
}
685686
catch (Exception e)
686687
{
687-
String errorMsg = e.getMessage();
688-
if (errorMsg == null)
689-
errorMsg = e.getClass().getName();
690-
return buildPreprocessorErrorMessage(originalDirective, errorMsg);
688+
StringWriter stringWriter = new StringWriter();
689+
e.printStackTrace(new PrintWriter(stringWriter));
690+
return buildPreprocessorErrorMessage(originalDirective, e.toString(), stringWriter.toString());
691691
}
692692
}
693693

694694
/**
695695
* Builds a user-friendly error message when a preprocessor directive fails.
696696
* <p>
697-
* The message includes the directive that failed, the error details, and instructions for
698-
* filing a bug report using {@code /cat:feedback}.
697+
* The message includes the directive that failed, the error details, the full stack trace for
698+
* debugging, and instructions for filing a bug report using {@code /cat:feedback}.
699699
*
700700
* @param originalDirective the original preprocessor directive text that failed
701-
* @param errorMsg the error message from the exception
701+
* @param errorMsg the error summary from the exception (via {@code toString()})
702+
* @param stackTrace the full stack trace from the exception
702703
* @return a user-friendly error message with bug report instructions
703704
*/
704-
private static String buildPreprocessorErrorMessage(String originalDirective, String errorMsg)
705+
static String buildPreprocessorErrorMessage(String originalDirective, String errorMsg,
706+
String stackTrace)
705707
{
706708
return """
707709
@@ -713,10 +715,15 @@ private static String buildPreprocessorErrorMessage(String originalDirective, St
713715
**Directive:** `%s`
714716
**Error:** %s
715717
718+
**Stack Trace:**
719+
```
720+
%s
721+
```
722+
716723
To report this bug, run: `/cat:feedback`
717724
---
718725
719-
""".formatted(originalDirective, errorMsg);
726+
""".formatted(originalDirective, errorMsg, stackTrace.stripTrailing());
720727
}
721728

722729
/**

client/src/test/java/io/github/cowwoc/cat/hooks/test/GetSkillTest.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2006,4 +2006,46 @@ public void runAcceptsJvmScopeWithoutStdin() throws IOException
20062006
TestUtils.deleteDirectoryRecursively(tempPluginRoot);
20072007
}
20082008
}
2009+
2010+
/**
2011+
* Verifies that a preprocessor error includes the full stack trace.
2012+
* <p>
2013+
* When a directive references a class that does not exist, the resulting error message must contain
2014+
* a {@code **Stack Trace:**} section with at least one stack frame line (containing {@code at }).
2015+
*
2016+
* @throws IOException if an I/O error occurs
2017+
*/
2018+
@Test
2019+
public void preprocessorErrorIncludesStackTrace() throws IOException
2020+
{
2021+
Path tempPluginRoot = Files.createTempDirectory("get-skill-test");
2022+
try (TestClaudeTool scope = new TestClaudeTool(tempPluginRoot, tempPluginRoot))
2023+
{
2024+
// Create a launcher file pointing to a class that does not exist so that Class.forName()
2025+
// throws ClassNotFoundException, triggering the catch (Exception e) block in invokeSkillOutput().
2026+
Path launcherDir = tempPluginRoot.resolve("client/bin");
2027+
Files.createDirectories(launcherDir);
2028+
Files.writeString(launcherDir.resolve("broken-launcher"),
2029+
"java -m test.module/com.example.nonexistent.BrokenClass\n",
2030+
UTF_8);
2031+
2032+
// Create a skill with a directive that references the broken launcher.
2033+
Path skillDir = tempPluginRoot.resolve("skills/broken-skill");
2034+
Files.createDirectories(skillDir);
2035+
Files.writeString(skillDir.resolve("first-use.md"),
2036+
"# Broken Skill\n!`\"broken-launcher\" arg1`\n",
2037+
UTF_8);
2038+
2039+
String agentId = UUID.randomUUID().toString();
2040+
GetSkill loader = new GetSkill(scope, List.of(agentId));
2041+
String output = loader.load("broken-skill");
2042+
2043+
requireThat(output, "output").contains("**Stack Trace:**");
2044+
requireThat(output, "output").contains("\tat ");
2045+
}
2046+
finally
2047+
{
2048+
TestUtils.deleteDirectoryRecursively(tempPluginRoot);
2049+
}
2050+
}
20092051
}

plugin/rules/skill-workflow-compliance.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,12 @@ mainAgent: true
88
**ALWAYS**: Execute every step in sequence; if step doesn't apply, note why and continue
99

1010
Skills exist to enforce consistent processes. Shortcuts defeat their purpose.
11+
12+
**Skills execute in the current agent's context — they are not subagents.**
13+
14+
When the Skill tool returns content, that content is the skill's instructions. The current agent
15+
executes those instructions directly. There is no subprocess, no background task, and no
16+
`<task-notification>` to wait for. After the Skill tool returns, act on the instructions immediately.
17+
18+
**NEVER** say "awaiting skill completion" or "the skill is running" — skills do not run independently.
19+
**ALWAYS** read the returned instructions and begin executing them in the next action.

0 commit comments

Comments
 (0)